Skip to main content
Glama
toolConfigStateMachine.ts16 kB
// Represents the state of a tool configuration process import { ConfigurableProp, V1Component } from "@pipedream/sdk" import { pd } from "../../pd-client" import { getAuthProvision } from "../authProvisions" import { componentMapper } from "../componentMapper" import { componentAppName } from "../utils" import { beginConfigurationTool } from "./begin-configuration-tool" import { queryTool } from "./query-tool" import { runActionTool } from "./run-action-tool" import { selectAppsTool } from "./select-apps-tool" import { abortConfigurationTool } from "./abort-configuration-tool" import { configurePropsToolTool } from "./configure-props-tool" import { asyncOptionsTool } from "./async-options-tool" import { runTool } from "./run-tool" import { ToolConfigState, InitialState, AvailableAppsState, AppsSelectedState, ComponentSelectedState, ConfiguringToolState, ToolConfigurationCompletedState, Stage, } from "./types" import { configureComponentTool } from "./configure-component-tool" import { WrappedTool } from "./wrapper" // Cache interfaces interface ComponentsCache { [appKey: string]: { components: V1Component[] timestamp: number } } interface SingleComponentCache { [componentKey: string]: { component: V1Component timestamp: number } } // Cache expiration time in milliseconds (12 hours) const CACHE_EXPIRATION = 12 * 60 * 60 * 1000 // Shared cache storage const componentsCache: ComponentsCache = {} const singleComponentCache: SingleComponentCache = {} // Cache management functions function isValidCache(timestamp: number): boolean { return Date.now() - timestamp < CACHE_EXPIRATION } function clearExpiredCache(): void { // Clear expired app components for (const [app, cache] of Object.entries(componentsCache)) { if (!isValidCache(cache.timestamp)) { delete componentsCache[app] } } // Clear expired single components for (const [key, cache] of Object.entries(singleComponentCache)) { if (!isValidCache(cache.timestamp)) { delete singleComponentCache[key] } } } // Run cache cleanup periodically (every hour) setInterval(clearExpiredCache, 60 * 60 * 1000) /** * ToolConfigStateMachine * * Manages the state of the tool configuration process for the Pipedream MCP server. * This tracks the user's progress through selecting apps, tools, and configuring properties. */ export class ToolConfigStateMachine { state: ToolConfigState uuid: string appSlugToHashid: Record<string, string> = {} constructor(uuid: string) { this.state = { stage: "INITIAL", } satisfies InitialState this.uuid = uuid } getState(): ToolConfigState { return { ...this.state } } /** * Reset the state machine to its initial state */ reset(): void { // TODO: Notify client about tool changes this.state = { stage: "INITIAL", } satisfies InitialState } isInConfigurationMode(): this is ToolConfigStateMachine & { state: ConfiguringToolState | ToolConfigurationCompletedState } { return this.isInStages(["CONFIGURING_TOOL", "TOOL_CONFIGURATION_COMPLETED"]) } /** * Type guard for component selected modes (TOOL_SELECTED, CONFIGURING_TOOL or TOOL_CONFIGURATION_COMPLETED) */ hasComponentSelected(): this is ToolConfigStateMachine & { state: | ComponentSelectedState | ConfiguringToolState | ToolConfigurationCompletedState } { return ( this.state.stage === "TOOL_SELECTED" || this.state.stage === "CONFIGURING_TOOL" || this.state.stage === "TOOL_CONFIGURATION_COMPLETED" ) } /** * Type guard for specific states */ isInStages(stages: Stage[]): this is ToolConfigStateMachine & { state: Extract<ToolConfigState, { stage: Stage }> } { return stages.includes(this.state.stage) } /** * Type guard for having apps selected */ hasAppsSelected(): this is ToolConfigStateMachine & { state: | AppsSelectedState | ComponentSelectedState | ConfiguringToolState | ToolConfigurationCompletedState } { return this.isInStages([ "APPS_SELECTED", "TOOL_SELECTED", "CONFIGURING_TOOL", "TOOL_CONFIGURATION_COMPLETED", ]) } /** * Type-safe state transitions */ transitionToAvailableApps(availableApps: string[]): void { this.state = { stage: "AVAILABLE_APPS", availableApps, } satisfies AvailableAppsState } transitionToAppsSelected(selectedApps: string[]): void { this.state = { stage: "APPS_SELECTED", selectedApps, availableApps: this.state.availableApps || [], } satisfies AppsSelectedState } transitionToComponentSelected(componentKey: string): void { if (this.state.stage !== "APPS_SELECTED") { throw new Error( "Can only transition to component selected from apps selected stage" ) } this.state = { stage: "TOOL_SELECTED", selectedApps: this.state.selectedApps, currentComponentKey: componentKey, } satisfies ComponentSelectedState } transitionToConfiguringTool(componentKey: string): void { if ( this.state.stage !== "APPS_SELECTED" && this.state.stage !== "TOOL_SELECTED" ) { throw new Error( "Can only transition to configuring tool from apps selected or tool selected stage" ) } this.state = { stage: "CONFIGURING_TOOL", selectedApps: this.state.selectedApps, availableApps: this.state.availableApps || [], currentComponentKey: componentKey, configuredProps: {}, shownProps: [], } satisfies ConfiguringToolState } transitionToToolConfigurationCompleted(): void { if (this.state.stage !== "CONFIGURING_TOOL") { throw new Error( "Can only transition to completed from configuring tool stage" ) } this.state = { ...this.state, stage: "TOOL_CONFIGURATION_COMPLETED", } satisfies ToolConfigurationCompletedState } /** * Calculate which properties to show next based on reload properties */ async getNextPropsToShow(): Promise<ConfigurableProp[]> { if (!this.isInConfigurationMode()) { throw new Error("Can only get next props in configuration mode") } const unshownProps = await this.getUnconfiguredProps() const reloadPropKeys = (await this.configurableProps()) .filter((cp) => cp.reloadProps) .map((cp) => cp.name) // Find index of the next reload prop that hasn't been shown yet let includeUpToIndex = unshownProps.length for (let i = 0; i < unshownProps.length; i++) { if (reloadPropKeys.includes(unshownProps[i].name)) { includeUpToIndex = i + 1 // Include this property and stop break } } // Return props up to and including the next reload prop return unshownProps.slice(0, includeUpToIndex) } async getComponents(): Promise<V1Component[]> { if (!this.hasAppsSelected()) { throw new Error("No apps selected") } const components: V1Component[] = [] const selectedApps = this.state.selectedApps || [] // Run app component fetching in parallel await Promise.all( selectedApps.map(async (app) => { // Check cache first const cachedData = componentsCache[app] if (cachedData && isValidCache(cachedData.timestamp)) { components.push(...cachedData.components) return } // If not in cache or expired, fetch from API const { data: appComponents } = await pd.getComponents({ app, componentType: "action", limit: 100, }) // Run individual component fetching in parallel const fetchedComponents = await Promise.all( appComponents.map(async (_component) => { // Check single component cache first const cachedComponent = singleComponentCache[_component.key] if (cachedComponent && isValidCache(cachedComponent.timestamp)) { return cachedComponent.component } // If not in cache or expired, fetch from API const { data: component } = await pd.getComponent({ key: _component.key, }) // Cache the component singleComponentCache[_component.key] = { component, timestamp: Date.now(), } return component }) ) // Cache the components for this app componentsCache[app] = { components: fetchedComponents, timestamp: Date.now(), } components.push(...fetchedComponents) }) ) return components } async getComponent(key: string) { // Check single component cache first const cachedComponent = singleComponentCache[key] if (cachedComponent && isValidCache(cachedComponent.timestamp)) { return cachedComponent.component } // If not in cache, try to find in app components cache first for (const appCache of Object.values(componentsCache)) { if (isValidCache(appCache.timestamp)) { const component = appCache.components.find((c) => c.key === key) if (component) { // Cache it in single component cache too singleComponentCache[key] = { component, timestamp: Date.now(), } return component } } } // If still not found, fetch all components and find it const components = await this.getComponents() return components.find((c) => c.key === key) } async getCurrentComponent() { if (!this.hasComponentSelected()) { return } return this.getComponent(this.state.currentComponentKey) } async getCurrentComponentAppHashid() { const component = await this.getCurrentComponent() if (!component) { return } const appSlug = componentAppName(component) if (!appSlug) { return } return this.appSlugToHashid[appSlug] } async getTools() { const tools = [ queryTool(this), selectAppsTool(this), configureComponentTool(this), ] .filter((t) => t.isActive(this.state.stage)) .reduce( (acc, t) => { acc[t.name] = t.tool return acc }, {} as Record<string, WrappedTool["tool"]> ) if (this.state.stage === "APPS_SELECTED") { const components = await this.getComponents() for (const component of components) { const hasDynamicProps = component.configurable_props.find( (cp: ConfigurableProp) => cp.reloadProps ) if (hasDynamicProps) { const tool = beginConfigurationTool({ machine: this, component, }) tools[tool.name] = tool.tool } else { const tool = await runActionTool({ machine: this, component, }) tools[tool.name] = tool.tool } } } if (this.state.stage === "CONFIGURING_TOOL") { const component = await this.getComponent(this.state.currentComponentKey) if (!component) { throw new Error("Component not found") } const abortTool = abortConfigurationTool({ machine: this, component, }) tools[abortTool.name] = abortTool.tool const { schema } = componentMapper( component, true, await this.getNextPropsToShow() ) const configurePropsTool = configurePropsToolTool({ machine: this, component, schema, }) tools[configurePropsTool.name] = configurePropsTool.tool // Async options const asyncOptions = (await this.configurableProps()).filter( (cp) => cp.remoteOptions ) // Async options that are currently inside schema const shownAsyncOptions = Object.keys(schema) asyncOptions .filter((ao) => shownAsyncOptions.includes(ao.name)) .forEach((option) => { const tool = asyncOptionsTool({ machine: this, component, option, }) tools[tool.name] = tool.tool }) } if ( this.state.stage === "TOOL_CONFIGURATION_COMPLETED" || (this.state.stage === "CONFIGURING_TOOL" && (await this.requiredPropsConfigured())) ) { const component = await this.configuringComponent if (!component) { throw new Error("Component not found") } const tool = runTool({ machine: this, component, }) tools[tool.name] = tool.tool } return tools } get configuringComponent(): Promise<V1Component | undefined> { if (!this.isInConfigurationMode()) { throw new Error("Not in configuration mode") } return this.getComponent(this.state.currentComponentKey) } async reloadProps() { if (this.state.stage !== "CONFIGURING_TOOL") { throw new Error("Not in configuration mode") } const component = await this.configuringComponent if (!component) { throw new Error("Component not found") } const app = componentAppName(component) if (!app) { throw new Error("App not found") } const { appKey } = componentMapper(component) if (!appKey) { throw new Error("App key not found") } const authProvisionResponse = await getAuthProvision({ app, uuid: this.uuid, }) if (typeof authProvisionResponse === "string") { return { content: [ { type: "text", text: authProvisionResponse, hashid: await this.getCurrentComponentAppHashid(), }, ], } } const response = await pd.reloadComponentProps({ componentId: { key: this.state.currentComponentKey, }, dynamicPropsId: this.state.dynamicPropsId, configuredProps: { ...this.state.configuredProps, [appKey]: { authProvisionId: authProvisionResponse.id, }, }, externalUserId: this.uuid, }) this.state.dynamicPropsId = response.dynamicProps.id this.state.dynamicProps = response.dynamicProps.configurableProps } async configurableProps() { if (!this.isInConfigurationMode()) { throw new Error("Not in configuration mode") } if (this.state.dynamicProps) { return this.state.dynamicProps } const component = await this.configuringComponent if (!component) { throw new Error("Component not found") } return component.configurable_props as ConfigurableProp[] } async getRemainingRequiredProps() { const props = await this.getUnconfiguredProps() return props.filter((cp) => cp.optional || cp.hidden) } async requiredPropsConfigured() { const component = await this.configuringComponent if (!component) { throw new Error("Component not found") } const props = await this.getUnconfiguredProps() return props .filter((cp) => !cp.optional && !cp.hidden && cp.type !== "app") .every((cp) => Object.keys(this.state.configuredProps || {}).includes(cp.name) ) } async getUnconfiguredProps(): Promise<ConfigurableProp[]> { if (!this.isInConfigurationMode()) { throw new Error("Can only get not shown props in configuration mode") } const component = await this.getCurrentComponent() if (!component) { throw new Error("Component not found") } const allProps = await this.configurableProps() const shownProps = this.state.shownProps return allProps.filter( (prop) => !shownProps.includes(prop.name) && !prop.hidden && prop.type !== "app" ) } async getRequiredProps(): Promise<ConfigurableProp[]> { const remainingProps = await this.getUnconfiguredProps() return remainingProps.filter((prop) => !prop.optional) } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rajnandan1/pd-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server