@toolbox-sdk/core
Version:
JavaScript Base SDK for interacting with the Toolbox service
182 lines • 9.86 kB
JavaScript
// 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 { ZodError } from 'zod';
import { logApiError } from './errorUtils.js';
import { identifyAuthRequirements, resolveValue, } from './utils.js';
/**
* 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) {
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, baseUrl, name, description, paramSchema, authTokenGetters = {}, requiredAuthnParams = {}, requiredAuthzTokens = [], boundParams = {}, clientHeaders = {}) {
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 = {}) {
if (Object.keys(requiredAuthnParams).length > 0 ||
requiredAuthzTokens.length > 0) {
const reqAuthServices = new Set();
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;
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;
}, {});
const headers = {};
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 = 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) {
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, getIdToken) {
return this.addAuthTokenGetters({ [authSource]: getIdToken });
};
callable.bindParams = function (paramsToBind) {
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, paramValue) {
return this.bindParams({ [paramName]: paramValue });
};
return callable;
}
export { ToolboxTool };
//# sourceMappingURL=tool.js.map