- Replaced the custom source configuration logic with a new `saveSourceWithCredentials` function in `AddSourceDialog`. - Updated `useAddSourceFormState` to manage authentication state revisions more effectively. - Removed unused functions and streamlined error handling related to credential input. - Enhanced user feedback on source addition and updates with clearer success messages.
Unified · Split
| @@ -17,19 +17,15 @@ import { convexApi } from "@/lib/convex-api"; · 16 unmodified lines | ||
| 17 | 17 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; |
| 18 | 18 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; |
| 19 | 19 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; |
| 20 | −import { sourceKeyForSource } from "@/lib/tools-source-helpers"; | |
| 21 | −import { | |
| 22 | − createCustomSourceConfig, | |
| 23 | −} from "./add-source-dialog-helpers"; | |
| 24 | 20 | import { |
| 25 | 21 | CatalogViewSection, |
| 26 | 22 | CustomViewSection, |
| 27 | 23 | } from "./add-source-dialog-sections"; |
| 28 | 24 | import { SourceAuthPanel } from "./add-source-auth-panel"; |
| 29 | 25 | import { |
| 30 | − existingCredentialMatchesAuthType, | |
| 31 | 26 | useAddSourceFormState, |
| 32 | 27 | } from "./use-add-source-form-state"; |
| 28 | +import { saveSourceWithCredentials } from "./add-source-submit"; | |
| 33 | 29 | |
| 34 | 30 | export function AddSourceDialog({ |
| 35 | 31 | existingSourceNames, |
| @@ -90,76 +86,34 @@ export function AddSourceDialog({ · 54 unmodified lines | ||
| 90 | 86 | |
| 91 | 87 | setSubmitting(true); |
| 92 | 88 | try { |
| 93 | − const authConfig = form.type === "openapi" || form.type === "graphql" | |
| 94 | − ? form.buildAuthConfig() | |
| 95 | − : undefined; | |
| 96 | − const config = createCustomSourceConfig({ | |
| 97 | − type: form.type, | |
| 98 | − endpoint: form.endpoint.trim(), | |
| 99 | − baseUrl: form.baseUrl, | |
| 100 | − auth: authConfig, | |
| 101 | − mcpTransport: form.mcpTransport, | |
| 102 | − actorId: context.actorId, | |
| 89 | + const result = await saveSourceWithCredentials({ | |
| 90 | + context, | |
| 91 | + sourceToEdit, | |
| 92 | + credentialsLoading, | |
| 93 | + upsertToolSource, | |
| 94 | + upsertCredential, | |
| 95 | + form: { | |
| 96 | + name: form.name, | |
| 97 | + endpoint: form.endpoint, | |
| 98 | + type: form.type, | |
| 99 | + baseUrl: form.baseUrl, | |
| 100 | + mcpTransport: form.mcpTransport, | |
| 101 | + authType: form.authType, | |
| 102 | + authScope: form.authScope, | |
| 103 | + existingScopedCredential: form.existingScopedCredential, | |
| 104 | + buildAuthConfig: form.buildAuthConfig, | |
| 105 | + hasCredentialInput: form.hasCredentialInput, | |
| 106 | + buildSecretJson: form.buildSecretJson, | |
| 107 | + }, | |
| 103 | 108 | }); |
| 104 | 109 | |
| 105 | − const created = await upsertToolSource({ | |
| 106 | − ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | |
| 107 | − workspaceId: context.workspaceId, | |
| 108 | − sessionId: context.sessionId, | |
| 109 | − name: form.name.trim(), | |
| 110 | − type: form.type, | |
| 111 | − config, | |
| 112 | − }) as ToolSourceRecord; | |
| 113 | − let linkedCredential = false; | |
| 114 | − | |
| 115 | − if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") { | |
| 116 | − const sourceKey = sourceKeyForSource(created); | |
| 117 | − if (!sourceKey) { | |
| 118 | − throw new Error("Failed to resolve source key for credentials"); | |
| 119 | − } | |
| 120 | − | |
| 121 | − if (form.authScope === "actor" && !context.actorId) { | |
| 122 | − throw new Error("Actor credentials require an authenticated actor"); | |
| 123 | − } | |
| 124 | − | |
| 125 | − const enteredCredential = form.hasCredentialInput(); | |
| 126 | − if (!enteredCredential && credentialsLoading) { | |
| 127 | − throw new Error("Loading existing connections, try again in a moment"); | |
| 128 | − } | |
| 129 | − | |
| 130 | − if (enteredCredential) { | |
| 131 | − const secret = form.buildSecretJson(); | |
| 132 | − if (!secret.value) { | |
| 133 | − throw new Error(secret.error ?? "Credential values are required"); | |
| 134 | − } | |
| 135 | − | |
| 136 | − await upsertCredential({ | |
| 137 | − ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}), | |
| 138 | − workspaceId: context.workspaceId, | |
| 139 | − sessionId: context.sessionId, | |
| 140 | − sourceKey, | |
| 141 | − scope: form.authScope, | |
| 142 | − ...(form.authScope === "actor" ? { actorId: context.actorId } : {}), | |
| 143 | − secretJson: secret.value, | |
| 144 | − }); | |
| 145 | − linkedCredential = true; | |
| 146 | − } else if (form.existingScopedCredential) { | |
| 147 | − if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) { | |
| 148 | − throw new Error("Enter credentials for the selected auth type"); | |
| 149 | − } | |
| 150 | − linkedCredential = true; | |
| 151 | − } else { | |
| 152 | − throw new Error("Enter credentials to finish setup"); | |
| 153 | − } | |
| 154 | − } | |
| 155 | − | |
| 156 | − onSourceAdded?.(created, { connected: linkedCredential }); | |
| 110 | + onSourceAdded?.(result.source, { connected: result.connected }); | |
| 157 | 111 | toast.success( |
| 158 | 112 | sourceToEdit |
| 159 | − ? linkedCredential | |
| 113 | + ? result.connected | |
| 160 | 114 | ? `Source "${form.name.trim()}" updated with credentials` |
| 161 | 115 | : `Source "${form.name.trim()}" updated` |
| 162 | − : linkedCredential | |
| 116 | + : result.connected | |
| 163 | 117 | ? `Source "${form.name.trim()}" added with credentials — loading tools…` |
| 164 | 118 | : `Source "${form.name.trim()}" added — loading tools…`, |
| 165 | 119 | ); |
| @@ -0,0 +1,129 @@ | ||
| 1 | +import { sourceKeyForSource } from "@/lib/tools-source-helpers"; | |
| 2 | +import type { | |
| 3 | + AnonymousContext, | |
| 4 | + CredentialRecord, | |
| 5 | + CredentialScope, | |
| 6 | + SourceAuthType, | |
| 7 | + ToolSourceRecord, | |
| 8 | +} from "@/lib/types"; | |
| 9 | +import { createCustomSourceConfig, type SourceType } from "./add-source-dialog-helpers"; | |
| 10 | +import { existingCredentialMatchesAuthType } from "./add-source-form-utils"; | |
| 11 | + | |
| 12 | +type UpsertToolSourceFn = (args: { | |
| 13 | + id?: ToolSourceRecord["id"]; | |
| 14 | + workspaceId: AnonymousContext["workspaceId"]; | |
| 15 | + sessionId: AnonymousContext["sessionId"]; | |
| 16 | + name: string; | |
| 17 | + type: SourceType; | |
| 18 | + config: Record<string, unknown>; | |
| 19 | +}) => Promise<unknown>; | |
| 20 | + | |
| 21 | +type UpsertCredentialFn = (args: { | |
| 22 | + id?: CredentialRecord["id"]; | |
| 23 | + workspaceId: AnonymousContext["workspaceId"]; | |
| 24 | + sessionId: AnonymousContext["sessionId"]; | |
| 25 | + sourceKey: string; | |
| 26 | + scope: CredentialScope; | |
| 27 | + actorId?: AnonymousContext["actorId"]; | |
| 28 | + secretJson: Record<string, unknown>; | |
| 29 | +}) => Promise<unknown>; | |
| 30 | + | |
| 31 | +type SaveFormSnapshot = { | |
| 32 | + name: string; | |
| 33 | + endpoint: string; | |
| 34 | + type: SourceType; | |
| 35 | + baseUrl: string; | |
| 36 | + mcpTransport: "auto" | "streamable-http" | "sse"; | |
| 37 | + authType: Exclude<SourceAuthType, "mixed">; | |
| 38 | + authScope: CredentialScope; | |
| 39 | + existingScopedCredential: CredentialRecord | null; | |
| 40 | + buildAuthConfig: () => Record<string, unknown> | undefined; | |
| 41 | + hasCredentialInput: () => boolean; | |
| 42 | + buildSecretJson: () => { value?: Record<string, unknown>; error?: string }; | |
| 43 | +}; | |
| 44 | + | |
| 45 | +export async function saveSourceWithCredentials({ | |
| 46 | + context, | |
| 47 | + sourceToEdit, | |
| 48 | + form, | |
| 49 | + credentialsLoading, | |
| 50 | + upsertToolSource, | |
| 51 | + upsertCredential, | |
| 52 | +}: { | |
| 53 | + context: AnonymousContext; | |
| 54 | + sourceToEdit?: ToolSourceRecord; | |
| 55 | + form: SaveFormSnapshot; | |
| 56 | + credentialsLoading: boolean; | |
| 57 | + upsertToolSource: UpsertToolSourceFn; | |
| 58 | + upsertCredential: UpsertCredentialFn; | |
| 59 | +}): Promise<{ source: ToolSourceRecord; connected: boolean }> { | |
| 60 | + const authConfig = form.type === "openapi" || form.type === "graphql" | |
| 61 | + ? form.buildAuthConfig() | |
| 62 | + : undefined; | |
| 63 | + | |
| 64 | + const config = createCustomSourceConfig({ | |
| 65 | + type: form.type, | |
| 66 | + endpoint: form.endpoint.trim(), | |
| 67 | + baseUrl: form.baseUrl, | |
| 68 | + auth: authConfig, | |
| 69 | + mcpTransport: form.mcpTransport, | |
| 70 | + actorId: context.actorId, | |
| 71 | + }); | |
| 72 | + | |
| 73 | + const created = await upsertToolSource({ | |
| 74 | + ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | |
| 75 | + workspaceId: context.workspaceId, | |
| 76 | + sessionId: context.sessionId, | |
| 77 | + name: form.name.trim(), | |
| 78 | + type: form.type, | |
| 79 | + config, | |
| 80 | + }) as ToolSourceRecord; | |
| 81 | + | |
| 82 | + let linkedCredential = false; | |
| 83 | + | |
| 84 | + if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") { | |
| 85 | + const sourceKey = sourceKeyForSource(created); | |
| 86 | + if (!sourceKey) { | |
| 87 | + throw new Error("Failed to resolve source key for credentials"); | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (form.authScope === "actor" && !context.actorId) { | |
| 91 | + throw new Error("Actor credentials require an authenticated actor"); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const enteredCredential = form.hasCredentialInput(); | |
| 95 | + if (!enteredCredential && credentialsLoading) { | |
| 96 | + throw new Error("Loading existing connections, try again in a moment"); | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (enteredCredential) { | |
| 100 | + const secret = form.buildSecretJson(); | |
| 101 | + if (!secret.value) { | |
| 102 | + throw new Error(secret.error ?? "Credential values are required"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + await upsertCredential({ | |
| 106 | + ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}), | |
| 107 | + workspaceId: context.workspaceId, | |
| 108 | + sessionId: context.sessionId, | |
| 109 | + sourceKey, | |
| 110 | + scope: form.authScope, | |
| 111 | + ...(form.authScope === "actor" ? { actorId: context.actorId } : {}), | |
| 112 | + secretJson: secret.value, | |
| 113 | + }); | |
| 114 | + linkedCredential = true; | |
| 115 | + } else if (form.existingScopedCredential) { | |
| 116 | + if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) { | |
| 117 | + throw new Error("Enter credentials for the selected auth type"); | |
| 118 | + } | |
| 119 | + linkedCredential = true; | |
| 120 | + } else { | |
| 121 | + throw new Error("Enter credentials to finish setup"); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + return { | |
| 126 | + source: created, | |
| 127 | + connected: linkedCredential, | |
| 128 | + }; | |
| 129 | +} | |
| @@ -35,6 +35,17 @@ function patchUi( · 34 unmodified lines | ||
| 35 | 35 | setUi((current) => ({ ...current, ...patch })); |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | +function patchUiWithAuthRevision( | |
| 39 | + setUi: Dispatch<SetStateAction<AddSourceUiState>>, | |
| 40 | + patch: Partial<AddSourceUiState> = {}, | |
| 41 | +) { | |
| 42 | + setUi((current) => ({ | |
| 43 | + ...current, | |
| 44 | + ...patch, | |
| 45 | + authRevision: current.authRevision + 1, | |
| 46 | + })); | |
| 47 | +} | |
| 48 | + | |
| 38 | 49 | export function useAddSourceFormState({ |
| 39 | 50 | open, |
| 40 | 51 | sourceToEdit, |
| @@ -88,7 +99,7 @@ export function useAddSourceFormState({ · 47 unmodified lines | ||
| 88 | 99 | if (sourceToEdit) { |
| 89 | 100 | const editValues = sourceToFormValues(sourceToEdit); |
| 90 | 101 | form.reset(editValues); |
| 91 | − patchUi(setUi, { | |
| 102 | + patchUiWithAuthRevision(setUi, { | |
| 92 | 103 | ...createDefaultUiState("custom"), |
| 93 | 104 | openApiBaseUrlOptions: editValues.baseUrl |
| 94 | 105 | ? [editValues.baseUrl] |
| @@ -97,16 +108,12 @@ export function useAddSourceFormState({ · 2 unmodified lines | ||
| 97 | 108 | return fallback ? [fallback] : []; |
| 98 | 109 | })(), |
| 99 | 110 | authManuallyEdited: Boolean(sourceToEdit.config.auth), |
| 100 | − authRevision: ui.authRevision + 1, | |
| 101 | 111 | }); |
| 102 | 112 | return; |
| 103 | 113 | } |
| 104 | 114 | |
| 105 | 115 | form.reset(createDefaultFormValues()); |
| 106 | − patchUi(setUi, { | |
| 107 | − ...createDefaultUiState("catalog"), | |
| 108 | − authRevision: ui.authRevision + 1, | |
| 109 | − }); | |
| 116 | + patchUiWithAuthRevision(setUi, createDefaultUiState("catalog")); | |
| 110 | 117 | }, [form, open, sourceToEdit]); |
| 111 | 118 | |
| 112 | 119 | const getTakenSourceNames = () => { |
| @@ -157,11 +164,10 @@ export function useAddSourceFormState({ · 44 unmodified lines | ||
| 157 | 164 | endpoint: item.specUrl, |
| 158 | 165 | baseUrl: defaultBaseUrl, |
| 159 | 166 | }); |
| 160 | − patchUi(setUi, { | |
| 167 | + patchUiWithAuthRevision(setUi, { | |
| 161 | 168 | view: "custom", |
| 162 | 169 | openApiBaseUrlOptions: defaultBaseUrl ? [defaultBaseUrl] : [], |
| 163 | 170 | authManuallyEdited: false, |
| 164 | − authRevision: ui.authRevision + 1, | |
| 165 | 171 | }); |
| 166 | 172 | }; |
| 167 | 173 | |
| @@ -180,10 +186,9 @@ export function useAddSourceFormState({ · 12 unmodified lines | ||
| 180 | 186 | return; |
| 181 | 187 | } |
| 182 | 188 | |
| 183 | − patchUi(setUi, { | |
| 189 | + patchUiWithAuthRevision(setUi, { | |
| 184 | 190 | openApiBaseUrlOptions: [], |
| 185 | 191 | authManuallyEdited: false, |
| 186 | − authRevision: ui.authRevision + 1, | |
| 187 | 192 | }); |
| 188 | 193 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 189 | 194 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| @@ -194,22 +199,22 @@ export function useAddSourceFormState({ · 4 unmodified lines | ||
| 194 | 199 | form.setValue("basicPassword", "", { shouldDirty: false, shouldTouch: false }); |
| 195 | 200 | }; |
| 196 | 201 | |
| 197 | − const bumpAuthRevision = () => patchUi(setUi, { authRevision: ui.authRevision + 1 }); | |
| 202 | + const bumpAuthRevision = () => patchUiWithAuthRevision(setUi); | |
| 198 | 203 | |
| 199 | 204 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { |
| 200 | 205 | form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true }); |
| 201 | − patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | |
| 206 | + patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); | |
| 202 | 207 | }; |
| 203 | 208 | |
| 204 | 209 | const handleAuthScopeChange = (authScope: CredentialScope) => { |
| 205 | 210 | form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true }); |
| 206 | − patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | |
| 211 | + patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); | |
| 207 | 212 | }; |
| 208 | 213 | |
| 209 | 214 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { |
| 210 | 215 | if (field === "apiKeyHeader") { |
| 211 | 216 | form.setValue("apiKeyHeader", value, { shouldDirty: true, shouldTouch: true }); |
| 212 | − patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | |
| 217 | + patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); | |
| 213 | 218 | return; |
| 214 | 219 | } |
| 215 | 220 | |
| @@ -1,5 +1,4 @@ | ||
| 1 | −import type { Doc } from "../_generated/dataModel"; | |
| 2 | −import type { AccountId, DbCtx, OrganizationId } from "./types"; | |
| 1 | +import type { DbCtx, OrganizationId } from "./types"; | |
| 3 | 2 | |
| 4 | 3 | export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { |
| 5 | 4 | return await ctx.db |
| @@ -28,14 +27,3 @@ export async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organization · 22 unmodified lines | ||
| 28 | 27 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) |
| 29 | 28 | .first(); |
| 30 | 29 | } |
| 31 | − | |
| 32 | −export async function getWorkspaceMembershipByWorkspaceAndAccount( | |
| 33 | − ctx: DbCtx, | |
| 34 | − workspaceId: Doc<"workspaces">["_id"], | |
| 35 | − accountId: AccountId, | |
| 36 | −) { | |
| 37 | − return await ctx.db | |
| 38 | − .query("workspaceMembers") | |
| 39 | − .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | |
| 40 | − .unique(); | |
| 41 | −} | |
| @@ -12,7 +12,7 @@ function deriveFallbackUserLabel(workosUserId: string): string { · 11 unmodified lines | ||
| 12 | 12 | return `User ${workosUserId.slice(-6)}`; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | −export function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | |
| 15 | +function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | |
| 16 | 16 | for (const key of keys) { |
| 17 | 17 | const value = identity[key]; |
| 18 | 18 | if (typeof value === "string" && value.trim().length > 0) { |
| @@ -2,7 +2,7 @@ import type { Doc } from "../_generated/dataModel"; · 1 unmodified line | ||
| 2 | 2 | import { asRecord } from "../lib/object"; |
| 3 | 3 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 4 | 4 | |
| 5 | −export function stableStringify(value: unknown): string { | |
| 5 | +function stableStringify(value: unknown): string { | |
| 6 | 6 | if (Array.isArray(value)) { |
| 7 | 7 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; |
| 8 | 8 | } |
| @@ -2,7 +2,7 @@ import { internal } from "../_generated/api"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | export type TaskEventName = "task" | "approval"; |
| 4 | 4 | |
| 5 | −export interface TaskEventMutationContext { | |
| 5 | +interface TaskEventMutationContext { | |
| 6 | 6 | runMutation: ( |
| 7 | 7 | mutation: typeof internal.database.createTaskEvent, |
| 8 | 8 | args: { |
| @@ -5,7 +5,7 @@ import type { TaskRecord, TaskStatus } from "../../core/src/types"; · 4 unmodified lines | ||
| 5 | 5 | type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; |
| 6 | 6 | type TaskFinishContext = Pick<ActionCtx, "runMutation"> | Pick<MutationCtx, "runMutation">; |
| 7 | 7 | |
| 8 | −export interface MarkTaskFinishedInput { | |
| 8 | +interface MarkTaskFinishedInput { | |
| 9 | 9 | taskId: string; |
| 10 | 10 | status: TerminalTaskStatus; |
| 11 | 11 | exitCode?: number; |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { TaskStatus } from "../../core/src/types"; |
| 2 | 2 | |
| 3 | −export type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | |
| 3 | +type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | |
| 4 | 4 | |
| 5 | 5 | export function isTerminalTaskStatus(status: TaskStatus): status is TerminalTaskStatus { |
| 6 | 6 | return status === "completed" || status === "failed" || status === "timed_out" || status === "denied"; |
| @@ -1,199 +1,4 @@ | ||
| 1 | 1 | import type { ToolDescriptor } from "./types"; |
| 2 | −import { | |
| 3 | − getSourceFileParseDiagnostics, | |
| 4 | − getTypeScriptModule, | |
| 5 | − indentBlock, | |
| 6 | − propertyNameText, | |
| 7 | −} from "./typechecker-ts-utils"; | |
| 8 | −import { OPENAPI_HELPER_TYPES } from "./openapi-helper-types"; | |
| 9 | − | |
| 10 | −function isValidTypeExpression(typeExpression: string): boolean { | |
| 11 | − const ts = getTypeScriptModule(); | |
| 12 | − if (!ts) { | |
| 13 | − return !/[\r\n`]/.test(typeExpression); | |
| 14 | − } | |
| 15 | − | |
| 16 | − return (() => { | |
| 17 | − try { | |
| 18 | − const sourceFile = ts.createSourceFile( | |
| 19 | − "_type_expr_check.ts", | |
| 20 | − `type __T = ${typeExpression};`, | |
| 21 | − ts.ScriptTarget.ESNext, | |
| 22 | − true, | |
| 23 | − ts.ScriptKind.TS, | |
| 24 | − ); | |
| 25 | − return getSourceFileParseDiagnostics(sourceFile).length === 0; | |
| 26 | − } catch { | |
| 27 | − return false; | |
| 28 | − } | |
| 29 | − })(); | |
| 30 | −} | |
| 31 | − | |
| 32 | −function safeTypeExpression(raw: string | undefined, fallback: string): string { | |
| 33 | − const typeExpression = raw?.trim(); | |
| 34 | − if (!typeExpression) return fallback; | |
| 35 | − return isValidTypeExpression(typeExpression) ? typeExpression : fallback; | |
| 36 | −} | |
| 37 | − | |
| 38 | −/** | |
| 39 | − * Build a minimal OpenAPI .d.ts containing only selected `operations` members. | |
| 40 | − * Returns null when slicing is not possible and callers should use full .d.ts. | |
| 41 | − */ | |
| 42 | −export function sliceOpenApiOperationsDts( | |
| 43 | − dts: string, | |
| 44 | − operationIds: Iterable<string>, | |
| 45 | −): string | null { | |
| 46 | − const ts = getTypeScriptModule(); | |
| 47 | − if (!ts) return null; | |
| 48 | − | |
| 49 | − const wanted = new Set([...operationIds].filter((value) => value.trim().length > 0)); | |
| 50 | − if (wanted.size === 0) return null; | |
| 51 | − | |
| 52 | − const sourceFile = ts.createSourceFile( | |
| 53 | − "openapi-source.d.ts", | |
| 54 | − dts, | |
| 55 | − ts.ScriptTarget.ESNext, | |
| 56 | − true, | |
| 57 | − ts.ScriptKind.TS, | |
| 58 | − ); | |
| 59 | − | |
| 60 | − const operationsInterface = sourceFile.statements.find((statement) => | |
| 61 | − ts.isInterfaceDeclaration(statement) | |
| 62 | − && statement.name.text === "operations", | |
| 63 | − ); | |
| 64 | − | |
| 65 | − if (!operationsInterface || !ts.isInterfaceDeclaration(operationsInterface)) { | |
| 66 | − return null; | |
| 67 | − } | |
| 68 | − | |
| 69 | − const selectedMembers: string[] = []; | |
| 70 | − for (const member of operationsInterface.members) { | |
| 71 | − if (!ts.isPropertySignature(member) || !member.name) continue; | |
| 72 | − const key = propertyNameText(member.name, ts); | |
| 73 | − if (!key || !wanted.has(key)) continue; | |
| 74 | − | |
| 75 | − const raw = dts.slice(member.getFullStart(), member.end).trim(); | |
| 76 | − if (raw.length > 0) { | |
| 77 | − selectedMembers.push(raw); | |
| 78 | − } | |
| 79 | − } | |
| 80 | − | |
| 81 | − if (selectedMembers.length === 0) return null; | |
| 82 | − | |
| 83 | − const body = selectedMembers.map((member) => indentBlock(member)).join("\n"); | |
| 84 | − return `export interface operations {\n${body}\n}`; | |
| 85 | −} | |
| 86 | − | |
| 87 | −function stripExportKeywordsForTypechecker(dts: string): string { | |
| 88 | − return dts.replace(/\bexport\s+/g, "").trim(); | |
| 89 | −} | |
| 90 | − | |
| 91 | −export interface GenerateToolDeclarationOptions { | |
| 92 | − sourceDtsBySource?: Record<string, string>; | |
| 93 | −} | |
| 94 | − | |
| 95 | −interface ToolTreeNode { | |
| 96 | − children: Map<string, ToolTreeNode>; | |
| 97 | − tool?: ToolDescriptor; | |
| 98 | −} | |
| 99 | − | |
| 100 | −function createToolTree(tools: ToolDescriptor[]): ToolTreeNode { | |
| 101 | − const root: ToolTreeNode = { children: new Map() }; | |
| 102 | − | |
| 103 | − for (const tool of tools) { | |
| 104 | − const segments = tool.path.split("."); | |
| 105 | − let node = root; | |
| 106 | − for (let i = 0; i < segments.length; i += 1) { | |
| 107 | − const segment = segments[i]; | |
| 108 | − if (!node.children.has(segment)) { | |
| 109 | − node.children.set(segment, { children: new Map() }); | |
| 110 | − } | |
| 111 | − node = node.children.get(segment)!; | |
| 112 | − if (i === segments.length - 1) { | |
| 113 | − node.tool = tool; | |
| 114 | − } | |
| 115 | − } | |
| 116 | − } | |
| 117 | − | |
| 118 | − return root; | |
| 119 | −} | |
| 120 | − | |
| 121 | −function renderToolCallSignature( | |
| 122 | − key: string, | |
| 123 | − tool: ToolDescriptor, | |
| 124 | − dtsSources: Set<string>, | |
| 125 | − pad: string, | |
| 126 | −): string { | |
| 127 | − if (tool.operationId && tool.source && dtsSources.has(tool.source)) { | |
| 128 | − const opKey = JSON.stringify(tool.operationId); | |
| 129 | − const openApiInputType = `ToolInput<operations[${opKey}]>`; | |
| 130 | − const openApiOutputType = `ToolOutput<operations[${opKey}]>`; | |
| 131 | − return `${pad}${key}(input: ${openApiInputType}): Promise<${openApiOutputType}>;`; | |
| 132 | − } | |
| 133 | − | |
| 134 | − const strictArgsType = tool.strictArgsType?.trim(); | |
| 135 | − const strictReturnsType = tool.strictReturnsType?.trim(); | |
| 136 | − const effectiveArgs = strictArgsType || tool.argsType; | |
| 137 | − const effectiveReturns = strictReturnsType || tool.returnsType; | |
| 138 | − const hasArgsType = Boolean(effectiveArgs?.trim()); | |
| 139 | − const args = safeTypeExpression(effectiveArgs, "Record<string, unknown>"); | |
| 140 | − const returns = safeTypeExpression(effectiveReturns, "unknown"); | |
| 141 | − const inputParam = !hasArgsType || args === "{}" | |
| 142 | − ? `input?: ${args}` | |
| 143 | − : `input: ${args}`; | |
| 144 | − | |
| 145 | − return `${pad}${key}(${inputParam}): Promise<${returns}>;`; | |
| 146 | −} | |
| 147 | − | |
| 148 | −function renderToolTree( | |
| 149 | − node: ToolTreeNode, | |
| 150 | − indent: number, | |
| 151 | − dtsSources: Set<string>, | |
| 152 | −): string { | |
| 153 | − const pad = " ".repeat(indent); | |
| 154 | − const lines: string[] = []; | |
| 155 | − | |
| 156 | − for (const [key, child] of node.children) { | |
| 157 | − if (child.tool) { | |
| 158 | − lines.push(renderToolCallSignature(key, child.tool, dtsSources, pad)); | |
| 159 | − continue; | |
| 160 | − } | |
| 161 | − | |
| 162 | − lines.push(`${pad}${key}: {`); | |
| 163 | − lines.push(renderToolTree(child, indent + 1, dtsSources)); | |
| 164 | − lines.push(`${pad}};`); | |
| 165 | − } | |
| 166 | − | |
| 167 | − return lines.join("\n"); | |
| 168 | −} | |
| 169 | − | |
| 170 | −/** | |
| 171 | − * Build a `declare const tools: { ... }` block from flat tool descriptors. | |
| 172 | − */ | |
| 173 | −export function generateToolDeclarations( | |
| 174 | − tools: ToolDescriptor[], | |
| 175 | − options?: GenerateToolDeclarationOptions, | |
| 176 | −): string { | |
| 177 | − const sourceDtsBySource = options?.sourceDtsBySource ?? {}; | |
| 178 | − const dtsSources = new Set(Object.keys(sourceDtsBySource)); | |
| 179 | − const root = createToolTree(tools); | |
| 180 | − | |
| 181 | − const parts: string[] = []; | |
| 182 | − | |
| 183 | − const dtsEntries = Object.entries(sourceDtsBySource) | |
| 184 | − .filter(([, dts]) => typeof dts === "string" && dts.trim().length > 0) | |
| 185 | − .sort(([a], [b]) => a.localeCompare(b)); | |
| 186 | − | |
| 187 | − if (dtsEntries.length > 0) { | |
| 188 | − parts.push(OPENAPI_HELPER_TYPES); | |
| 189 | − for (const [sourceKey, dts] of dtsEntries) { | |
| 190 | − parts.push(`// OpenAPI types from ${sourceKey}\n${stripExportKeywordsForTypechecker(dts)}`); | |
| 191 | − } | |
| 192 | − } | |
| 193 | − | |
| 194 | − parts.push(`declare const tools: {\n${renderToolTree(root, 1, dtsSources)}\n};`); | |
| 195 | − return parts.join("\n"); | |
| 196 | −} | |
| 197 | 2 | |
| 198 | 3 | /** |
| 199 | 4 | * Generate the tool inventory text for the MCP run_code description. |
| @@ -4,46 +4,3 @@ export interface LiveTaskEvent { · 3 unmodified lines | ||
| 4 | 4 | payload: unknown; |
| 5 | 5 | createdAt: number; |
| 6 | 6 | } |
| 7 | − | |
| 8 | −type Subscriber = (event: LiveTaskEvent) => void; | |
| 9 | − | |
| 10 | −export class TaskEventHub { | |
| 11 | − private readonly subscribers = new Map<string, Set<Subscriber>>(); | |
| 12 | − | |
| 13 | − publish(taskId: string, event: LiveTaskEvent): void { | |
| 14 | − const listeners = this.subscribers.get(taskId); | |
| 15 | − if (!listeners || listeners.size === 0) { | |
| 16 | − return; | |
| 17 | − } | |
| 18 | − | |
| 19 | − for (const listener of listeners) { | |
| 20 | − try { | |
| 21 | − listener(event); | |
| 22 | − } catch { | |
| 23 | − listeners.delete(listener); | |
| 24 | − } | |
| 25 | − } | |
| 26 | − | |
| 27 | − if (listeners.size === 0) { | |
| 28 | − this.subscribers.delete(taskId); | |
| 29 | − } | |
| 30 | − } | |
| 31 | − | |
| 32 | − subscribe(taskId: string, listener: Subscriber): () => void { | |
| 33 | − const listeners = this.subscribers.get(taskId) ?? new Set<Subscriber>(); | |
| 34 | − listeners.add(listener); | |
| 35 | − this.subscribers.set(taskId, listeners); | |
| 36 | − | |
| 37 | − return () => { | |
| 38 | − const active = this.subscribers.get(taskId); | |
| 39 | − if (!active) { | |
| 40 | − return; | |
| 41 | − } | |
| 42 | − | |
| 43 | − active.delete(listener); | |
| 44 | − if (active.size === 0) { | |
| 45 | − this.subscribers.delete(taskId); | |
| 46 | − } | |
| 47 | − }; | |
| 48 | − } | |
| 49 | −} | |
| @@ -22,7 +22,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 21 unmodified lines | ||
| 22 | 22 | return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase()); |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | −export function isCloudflareDynamicWorkerOnlyMode(): boolean { | |
| 25 | +function isCloudflareDynamicWorkerOnlyMode(): boolean { | |
| 26 | 26 | return isTruthyEnvValue(process.env[CLOUDFLARE_DYNAMIC_WORKER_ONLY_ENV_KEY]); |
| 27 | 27 | } |
| 28 | 28 | |
| @@ -6,7 +6,7 @@ import { Result, TaggedError } from "better-result"; · 5 unmodified lines | ||
| 6 | 6 | // Errors |
| 7 | 7 | // --------------------------------------------------------------------------- |
| 8 | 8 | |
| 9 | −export class TranspileError extends TaggedError("TranspileError")<{ | |
| 9 | +class TranspileError extends TaggedError("TranspileError")<{ | |
| 10 | 10 | message: string; |
| 11 | 11 | }>() {} |
| 12 | 12 | |
| @@ -55,7 +55,7 @@ function simplifyNamespaceSegment(segment: string): string { · 54 unmodified lines | ||
| 55 | 55 | return collapsed.join("_"); |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | −export function preferredToolPath(path: string): string { | |
| 58 | +function preferredToolPath(path: string): string { | |
| 59 | 59 | const segments = path.split(".").filter(Boolean); |
| 60 | 60 | if (segments.length === 0) return path; |
| 61 | 61 | |
| @@ -13,7 +13,7 @@ export interface GraphqlExecutionEnvelope { · 12 unmodified lines | ||
| 13 | 13 | errors: unknown[]; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | −export function isMcpReconnectableError(error: unknown): boolean { | |
| 16 | +function isMcpReconnectableError(error: unknown): boolean { | |
| 17 | 17 | const message = error instanceof Error ? error.message : String(error); |
| 18 | 18 | return /(socket|closed|ECONNRESET|fetch failed)/i.test(message); |
| 19 | 19 | } |
| @@ -106,14 +106,14 @@ export async function executeOpenApiRequest( · 86 unmodified lines | ||
| 106 | 106 | return await response.text(); |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | −export function hasGraphqlData(data: unknown): boolean { | |
| 109 | +function hasGraphqlData(data: unknown): boolean { | |
| 110 | 110 | if (data === null || data === undefined) return false; |
| 111 | 111 | if (Array.isArray(data)) return data.length > 0; |
| 112 | 112 | if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0; |
| 113 | 113 | return true; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | −export function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope { | |
| 116 | +function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope { | |
| 117 | 117 | return { |
| 118 | 118 | data: result.data ?? null, |
| 119 | 119 | errors: Array.isArray(result.errors) ? result.errors : [], |
| @@ -21,7 +21,7 @@ import type { SerializedTool } from "../tool-source-serialization"; · 20 unmodified lines | ||
| 21 | 21 | const POSTMAN_SPEC_PREFIX = "postman:"; |
| 22 | 22 | const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy"; |
| 23 | 23 | |
| 24 | −export function parsePostmanCollectionUid(spec: string): string | null { | |
| 24 | +function parsePostmanCollectionUid(spec: string): string | null { | |
| 25 | 25 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { |
| 26 | 26 | return null; |
| 27 | 27 | } |
| @@ -24,52 +24,6 @@ export { prepareOpenApiSpec } from "./openapi-prepare"; · 23 unmodified lines | ||
| 24 | 24 | export { parseGraphqlOperationPaths } from "./graphql-operation-paths"; |
| 25 | 25 | export { rehydrateTools, serializeTools, type SerializedTool } from "./tool-source-serialization"; |
| 26 | 26 | export { buildOpenApiToolsFromPrepared } from "./openapi-tool-builder"; |
| 27 | −export { parsePostmanCollectionUid } from "./tool-source-loaders/openapi-loader"; | |
| 28 | − | |
| 29 | −function isRecord(value: unknown): value is Record<string, unknown> { | |
| 30 | − return typeof value === "object" && value !== null; | |
| 31 | −} | |
| 32 | − | |
| 33 | −function isNonEmptyString(value: unknown): value is string { | |
| 34 | − return typeof value === "string" && value.trim().length > 0; | |
| 35 | −} | |
| 36 | − | |
| 37 | −function isExternalToolSourceType(value: unknown): value is ExternalToolSourceConfig["type"] { | |
| 38 | − return value === "mcp" || value === "openapi" || value === "graphql"; | |
| 39 | −} | |
| 40 | − | |
| 41 | −export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] { | |
| 42 | − if (!raw || raw.trim().length === 0) { | |
| 43 | − return []; | |
| 44 | − } | |
| 45 | − | |
| 46 | − let parsed: unknown; | |
| 47 | − try { | |
| 48 | − parsed = JSON.parse(raw) as unknown; | |
| 49 | − } catch (error) { | |
| 50 | − const message = error instanceof Error ? error.message : String(error); | |
| 51 | − throw new Error(`EXECUTOR_TOOL_SOURCES must be valid JSON: ${message}`); | |
| 52 | − } | |
| 53 | − | |
| 54 | − if (!Array.isArray(parsed)) { | |
| 55 | − throw new Error("EXECUTOR_TOOL_SOURCES must be a JSON array"); | |
| 56 | − } | |
| 57 | − | |
| 58 | − for (let i = 0; i < parsed.length; i += 1) { | |
| 59 | − const entry = parsed[i]; | |
| 60 | − if (!isRecord(entry)) { | |
| 61 | − throw new Error(`EXECUTOR_TOOL_SOURCES[${i}] must be an object`); | |
| 62 | − } | |
| 63 | − if (!isExternalToolSourceType(entry.type)) { | |
| 64 | − throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].type must be one of: mcp, openapi, graphql`); | |
| 65 | − } | |
| 66 | − if (!isNonEmptyString(entry.name)) { | |
| 67 | − throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].name must be a non-empty string`); | |
| 68 | − } | |
| 69 | − } | |
| 70 | − | |
| 71 | − return parsed as ExternalToolSourceConfig[]; | |
| 72 | −} | |
| 73 | 27 | |
| 74 | 28 | export interface CompiledToolSourceArtifact { |
| 75 | 29 | version: "v1"; |
| @@ -188,7 +188,7 @@ export function extractTopLevelTypeKeys(typeHint: string): string[] { · 187 unmodified lines | ||
| 188 | 188 | return keys; |
| 189 | 189 | } |
| 190 | 190 | |
| 191 | −export function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] { | |
| 191 | +function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] { | |
| 192 | 192 | const direct = extractTopLevelTypeKeys(typeHint); |
| 193 | 193 | if (direct.length > 0) return direct; |
| 194 | 194 | |
| @@ -1,55 +0,0 @@ | ||
| 1 | −import { Result } from "better-result"; | |
| 2 | − | |
| 3 | −export function getTypeScriptModule(): typeof import("typescript") | null { | |
| 4 | − const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 5 | − return loaded.isOk() ? loaded.value : null; | |
| 6 | −} | |
| 7 | − | |
| 8 | −export function getSourceFileParseDiagnostics( | |
| 9 | − sourceFile: import("typescript").SourceFile, | |
| 10 | −): import("typescript").Diagnostic[] { | |
| 11 | − return ( | |
| 12 | − sourceFile as { parseDiagnostics?: import("typescript").Diagnostic[] } | |
| 13 | − ).parseDiagnostics ?? []; | |
| 14 | −} | |
| 15 | − | |
| 16 | −export function unwrapExpression( | |
| 17 | − expression: import("typescript").Expression, | |
| 18 | − ts: typeof import("typescript"), | |
| 19 | −): import("typescript").Expression { | |
| 20 | − let current = expression; | |
| 21 | − while ( | |
| 22 | − ts.isParenthesizedExpression(current) | |
| 23 | − || ts.isAsExpression(current) | |
| 24 | − || ts.isTypeAssertionExpression(current) | |
| 25 | − || ts.isNonNullExpression(current) | |
| 26 | − || ts.isPartiallyEmittedExpression(current) | |
| 27 | − || ts.isSatisfiesExpression(current) | |
| 28 | − ) { | |
| 29 | − current = current.expression; | |
| 30 | − } | |
| 31 | − return current; | |
| 32 | −} | |
| 33 | − | |
| 34 | −export function propertyNameText( | |
| 35 | − name: import("typescript").PropertyName, | |
| 36 | − ts: typeof import("typescript"), | |
| 37 | −): string | null { | |
| 38 | − if (ts.isIdentifier(name)) return name.text; | |
| 39 | − if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text; | |
| 40 | − if (ts.isNumericLiteral(name)) return name.text; | |
| 41 | − if (ts.isComputedPropertyName(name)) { | |
| 42 | − const expression = unwrapExpression(name.expression, ts); | |
| 43 | − if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) || ts.isNumericLiteral(expression)) { | |
| 44 | − return expression.text; | |
| 45 | − } | |
| 46 | − } | |
| 47 | − return null; | |
| 48 | −} | |
| 49 | − | |
| 50 | −export function indentBlock(text: string, indent = " "): string { | |
| 51 | − return text | |
| 52 | − .split("\n") | |
| 53 | − .map((line) => (line.trim().length === 0 ? "" : `${indent}${line}`)) | |
| 54 | − .join("\n"); | |
| 55 | −} | |