UNPKG

@toolbox-sdk/core

Version:
310 lines (281 loc) 10.6 kB
// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {ZodObject, ZodError, ZodRawShape} from 'zod'; import {AxiosInstance, AxiosResponse} from 'axios'; import {logApiError} from './errorUtils.js'; import { BoundParams, BoundValue, identifyAuthRequirements, resolveValue, } from './utils.js'; import {ClientHeadersConfig} from './client.js'; export type AuthTokenGetter = () => string | Promise<string>; export type AuthTokenGetters = Record<string, AuthTokenGetter>; export type RequiredAuthnParams = Record<string, string[]>; /** * A helper function to get the formatted auth token header name. * @param {string} authTokenName - The name of the authentication service. * @returns {string} The formatted header name. */ function getAuthHeaderName(authTokenName: string): string { return `${authTokenName}_token`; } /** * Creates a callable tool function representing a specific tool on a remote * Toolbox server. * * @param {AxiosInstance} session - The Axios session for making HTTP requests. * @param {string} baseUrl - The base URL of the Toolbox Server API. * @param {string} name - The name of the remote tool. * @param {string} description - A description of the remote tool. * @param {ZodObject<any>} paramSchema - The Zod schema for validating the tool's parameters. * @param {AuthTokenGetters} [authTokenGetters] - Optional map of auth service names to token getters. * @param {RequiredAuthnParams} [requiredAuthnParams] - Optional map of auth params that still need satisfying. * @param {string[]} [requiredAuthzTokens] - Optional list of auth tokens that still need satisfying. * @param {BoundParams} [boundParams] - Optional parameters to pre-bind to the tool. * @param {ClientHeadersConfig} [clientHeaders] - Optional client-specific headers. * @returns {CallableTool & CallableToolProperties} An async function that, when * called, invokes the tool with the provided arguments. */ function ToolboxTool( session: AxiosInstance, baseUrl: string, name: string, description: string, paramSchema: ZodObject<ZodRawShape>, authTokenGetters: AuthTokenGetters = {}, requiredAuthnParams: RequiredAuthnParams = {}, requiredAuthzTokens: string[] = [], boundParams: BoundParams = {}, clientHeaders: ClientHeadersConfig = {}, ) { if ( (Object.keys(authTokenGetters).length > 0 || Object.keys(clientHeaders).length > 0) && !baseUrl.startsWith('https://') ) { console.warn( 'Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication.', ); } const requestHeaderNames = Object.keys(clientHeaders); const authTokenNames = Object.keys(authTokenGetters).map(getAuthHeaderName); const duplicates = requestHeaderNames.filter(h => authTokenNames.includes(h)); if (duplicates.length > 0) { throw new Error( `Client header(s) \`${duplicates.join(', ')}\` already registered in client. Cannot register the same headers in the client as well as tool.`, ); } const toolUrl = `${baseUrl}/api/tool/${name}/invoke`; // Only omit keys that actually exist in the provided schema. // This handles cases where `paramSchema` is already partial. // Could be partial due to bound params being used while loading tools. const boundKeys = Object.keys(boundParams); const existingSchemaKeys = Object.keys(paramSchema.shape); const keysToOmit = boundKeys.filter(key => existingSchemaKeys.includes(key)); const userParamSchema = paramSchema.omit( Object.fromEntries(keysToOmit.map(k => [k, true])), ); const callable = async function ( callArguments: Record<string, unknown> = {}, ) { if ( Object.keys(requiredAuthnParams).length > 0 || requiredAuthzTokens.length > 0 ) { const reqAuthServices = new Set<string>(); Object.values(requiredAuthnParams).forEach(services => services.forEach(s => reqAuthServices.add(s)), ); requiredAuthzTokens.forEach(s => reqAuthServices.add(s)); throw new Error( `One or more of the following authn services are required to invoke this tool: ${[ ...reqAuthServices, ].join(',')}`, ); } let validatedUserArgs: Record<string, unknown>; try { validatedUserArgs = userParamSchema.parse(callArguments); } catch (error) { if (error instanceof ZodError) { const errorMessages = error.issues.map( e => `${e.path.join('.') || 'payload'}: ${e.message}`, ); throw new Error( `Argument validation failed for tool "${name}":\n - ${errorMessages.join( '\n - ', )}`, ); } throw new Error(`Argument validation failed: ${String(error)}`); } const resolvedEntries = await Promise.all( Object.entries(boundParams).map(async ([key, value]) => { const resolved = await resolveValue(value); return [key, resolved]; }), ); const resolvedBoundParams = Object.fromEntries(resolvedEntries); const payload = {...validatedUserArgs, ...resolvedBoundParams}; // Filter out null values from the payload const filteredPayload = Object.entries(payload).reduce( (acc, [key, value]) => { if (value !== null && value !== undefined) { acc[key] = value; } return acc; }, {} as Record<string, unknown>, ); const headers: Record<string, string> = {}; for (const [headerName, headerValue] of Object.entries(clientHeaders)) { const resolvedHeaderValue = await resolveValue(headerValue); if (typeof resolvedHeaderValue !== 'string') { throw new Error( `Client header '${headerName}' did not resolve to a string.`, ); } headers[headerName] = resolvedHeaderValue; } for (const [authService, tokenGetter] of Object.entries(authTokenGetters)) { const token = await resolveValue(tokenGetter); if (typeof token !== 'string') { throw new Error( `Auth token getter for '${authService}' did not return a string.`, ); } headers[getAuthHeaderName(authService)] = token; } try { const response: AxiosResponse = await session.post( toolUrl, filteredPayload, { headers, }, ); return response.data.result; } catch (error) { logApiError(`Error posting data to ${toolUrl}:`, error); throw error; } }; callable.toolName = name; callable.description = description; callable.params = paramSchema; callable.boundParams = boundParams; callable.authTokenGetters = authTokenGetters; callable.requiredAuthnParams = requiredAuthnParams; callable.requiredAuthzTokens = requiredAuthzTokens; callable.clientHeaders = clientHeaders; callable.getName = function () { return this.toolName; }; callable.getDescription = function () { return this.description; }; callable.getParamSchema = function () { return this.params; }; callable.addAuthTokenGetters = function ( newAuthTokenGetters: AuthTokenGetters, ) { const existingServices = Object.keys(this.authTokenGetters); const incomingServices = Object.keys(newAuthTokenGetters); const duplicates = existingServices.filter(s => incomingServices.includes(s), ); if (duplicates.length > 0) { throw new Error( `Authentication source(s) \`${duplicates.join(', ')}\` already registered in tool \`${this.toolName}\`.`, ); } const requestHeaderNames = Object.keys(this.clientHeaders); const authTokenNames = incomingServices.map(getAuthHeaderName); const headerDuplicates = requestHeaderNames.filter(h => authTokenNames.includes(h), ); if (headerDuplicates.length > 0) { throw new Error( `Client header(s) \`${headerDuplicates.join(', ')}\` already registered in client. Cannot register the same headers in the client as well as tool.`, ); } const combinedGetters = {...this.authTokenGetters, ...newAuthTokenGetters}; const [newReqAuthnParams, newReqAuthzTokens, usedServices] = identifyAuthRequirements( this.requiredAuthnParams, this.requiredAuthzTokens, Object.keys(newAuthTokenGetters), ); const unusedAuth = incomingServices.filter(s => !usedServices.has(s)); if (unusedAuth.length > 0) { throw new Error( `Authentication source(s) \`${unusedAuth.join(', ')}\` unused by tool \`${this.toolName}\`.`, ); } return ToolboxTool( session, baseUrl, this.toolName, this.description, this.params, combinedGetters, newReqAuthnParams, newReqAuthzTokens, this.boundParams, this.clientHeaders, ); }; callable.addAuthTokenGetter = function ( authSource: string, getIdToken: AuthTokenGetter, ) { return this.addAuthTokenGetters({[authSource]: getIdToken}); }; callable.bindParams = function (paramsToBind: BoundParams) { const originalParamKeys = Object.keys(this.params.shape); for (const paramName of Object.keys(paramsToBind)) { if (paramName in this.boundParams) { throw new Error( `Cannot re-bind parameter: parameter '${paramName}' is already bound in tool '${this.toolName}'.`, ); } if (!originalParamKeys.includes(paramName)) { throw new Error( `Unable to bind parameter: no parameter named '${paramName}' in tool '${this.toolName}'.`, ); } } const newBoundParams = {...this.boundParams, ...paramsToBind}; return ToolboxTool( session, baseUrl, this.toolName, this.description, this.params, this.authTokenGetters, this.requiredAuthnParams, this.requiredAuthzTokens, newBoundParams, this.clientHeaders, ); }; callable.bindParam = function (paramName: string, paramValue: BoundValue) { return this.bindParams({[paramName]: paramValue}); }; return callable; } export {ToolboxTool};