@toolbox-sdk/core
Version:
JavaScript Base SDK for interacting with the Toolbox service
310 lines (281 loc) • 10.6 kB
text/typescript
// 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};