npm package: @optimizely-opal/opal-tool-ocp-sdk
Description: OCP SDK for Opal tool
Author: Optimizely, Inc
Latest version: 1.1.5

# OPAL TOOL OCP SDK

> **Optimizely Connect Platform (OCP) SDK for OPAL Tool**

A TypeScript SDK for building Opal tools in Optimizely Connect Platform. This SDK provides decorators, abstractions, and utilities to simplify the development.

## Features

- 🎯 **Decorator-based Tool Registration** - Use `@tool` decorator to easily register functions
- 🌐 **Global and Regular Function Modes** - SDK can be used in either global or organization-scoped mode
- 🔧 **Type-safe Development** - Full TypeScript support with comprehensive type definitions  
- 🏗️ **Abstract Base Classes** - Extend `ToolFunction` or `GlobalToolFunction` for standardized request processing
- 🔐 **Authentication Support** - OptiID authentication (default), custom auth providers supported
- 🛡️ **Authorization Support** - OptiID token tool authorization
- 📝 **Parameter Validation** - Define and validate tool parameters with types
- ✅ **Automatic Validation** - SDK automatically validates parameters and returns RFC 9457 compliant error responses
- 🧪 **Comprehensive Testing** - Fully tested with Jest

## Quick Start

The SDK extends the functionality of OCP apps. You need to have an OCP app to use this SDK. 
Learn how to get started with OCP app development [here](https://docs.developers.optimizely.com/optimizely-connect-platform/docs/developer-platform-overview-ocp2).

Start by adding the SDK to your existing OCP app. In your OCP app folder, execute: 
```bash
yarn add @optimizely-opal/opal-tool-ocp-sdk
```

To add an Opal too registry to your OCP app, 
add a [function](https://docs.developers.optimizely.com/optimizely-connect-platform/docs/functions-ocp2) to your app manifest and mark it as an Opal tool function:

**`app.yml`**
```yaml
functions: 
  opal_tool: # A unique key for your function. 
    entry_point: OpalToolFunction # The name of the class implementing your tool. 
    description: Opal tool function # A brief description of this function.
    opal_tool: true
```

Next, create and implement function class - both file name and class must match the value of `entry_point` property from app manifest. 
Function class must extend either `ToolFunction` or `GlobalToolFunction` class from `@optimizely-opal/opal-tool-ocp-sdk` (See 'Function modes' section below).

**`src/functions/OpalToolFunction`**
```typescript
import { ToolFunction } from '@optimizely-opal/opal-tool-ocp-sdk';

export class OpalToolFunction extends ToolFunction {

}
```

Next, implement tools methods and annotate them with `@tool` decorator. Each such method is a tool in your registry. 
You can define mulitple tool methods in your app. 
Tool methods can be defined either as tool class instance methods or in separate classes. 
If tools are defined in separate classes, these classes need to be imported into the function class. 

**`src/functions/OpalToolFunction`**
```typescript
import { ToolFunction } from '@optimizely-opal/opal-tool-ocp-sdk';

export class OpalToolFunction extends ToolFunction {

  @tool({
    name: 'create_task',
    description: 'Creates a new task in the system',
    endpoint: '/create-task',
    parameters: [
      {
        name: 'title',
        type: ParameterType.String,
        description: 'The task title',
        required: true
      },
      {
        name: 'priority',
        type: ParameterType.String,
        description: 'Task priority level',
        required: false
      }
    ]
  })
  async createTask(params: { title: string; priority?: string }, authData: OptiIdAuthData) {
    return {
      id: '123',
      title: params.title,
      priority: params.priority || 'medium'
    };
  }
}
```

The format of `params` attribute matches the parameters defined in `@tool` decorator. 

When the function is called by Opal, the SDK automatically: 

- **Routes requests** to your registered tools based on endpoints
- **Handles authentication** and OptiID token validation before calling your methods  
- **Provides discovery** at `/discovery` endpoint for OCP platform integration
- **Returns proper HTTP responses** with correct status codes and JSON formatting

Optionally, implement `ready` method to define when tool registry can be registered and called by Opal. 
OCP will call this method to show tool readiness in the UI. Only functions marked as `ready` can be registered and called by Opal.
The value of `reason` property from the response will be displayed in OCP UI to inform users why the tool is not ready to be registered. 
```typescript
  protected override async ready(): Promise<ReadyResponse> {
    // validation logic
    if (!isValid) {
      return { ready: false, reason: 'Configure the app first.' };
    }

    return { ready: true };
  }
```

## Core Concepts

### Tools

Each OCP app with an Opal too function is a tool registry in Opal. Tool registry constist of one or more tools. 
Each tool have name, description and a list of input parameters.

### Function Modes

#### Regular Functions (function extends `ToolFunction` class)

Regular functions are scoped to specific organizations and validate that requests come from the same organization:

- Validate OptiID organization ID matches the function's organization context
- All tools within the function are organization-scoped
- **Per-Organization Configuration**: Can implement organization-specific configuration, authentication credentials, and API keys since they're tied to a single organization
- **Per-Organization Authentication**: Can store and use organization-specific authentication tokens, connection strings, and other sensitive data securely

#### Global Functions (function extends `GlobalToolFunction` class)

Global functions work across all organizations without organization validation:

- Accept OptiID authentication
- All tools within the function are platform-wide
- **No Per-Organization Configuration**: Cannot implement per-organization configuration since they work across all organizations
- **No Per-Organization Authentication**: Cannot store organization-specific credentials or authentication data
- **Global Discovery**: Have a global discovery URL that can be used by any organization without requiring them to install the app first

### Parameters

Supported parameter types:

```typescript
enum ParameterType {
  String = 'string',
  Integer = 'integer', 
  Number = 'number',
  Boolean = 'boolean',
  List = 'list',
  Dictionary = 'object'
}
```

### Parameter Validation

The SDK automatically validates all incoming parameters against the parameter definitions you specify for your tools. 
When Opal sends requests to your tools, the SDK performs validation before calling your handler methods and automatically returns an error message that Opal understands. 
This allow Opal to auto-correct. 

#### Automatic Validation Features

- **Type Checking**: Ensures parameters match their defined types (string, integer, number, boolean, list, object)
- **Required Validation**: Verifies that all required parameters are present and not null/undefined
- **Early Error Response**: Returns validation errors immediately without calling your handler if validation fails
- **RFC 9457 Compliance**: Error responses follow the RFC 9457 Problem Details for HTTP APIs specification

### Authentication

The SDK supports authentication and authorization mechanisms:

#### OptiID Authentication (Default)

OptiID provides user authentication with type safety. When a tool does not declare any `authRequirements`, the SDK automatically adds OptiID as the default authentication provider:

```typescript
interface AuthRequirementConfig {
  provider: string;        // 'OptiID'
  scopeBundle: string;     // e.g., 'calendar', 'tasks'
  required?: boolean;      // default: true
}
```

#### OptiID Token Authorization

The SDK automatically handles OptiID token validation for tool authorization. OptiID tokens provide both user authentication and authorization for tools, ensuring that only authenticated users with proper permissions can access your tools.

**Token Validation:**
- The SDK extracts and validates OptiID tokens from the request body
- **Regular Tools**: Validation includes verifying that requests come from the same organization as the tool
- **Global Tools**: Token validation occurs but organization ID matching is skipped
- If validation fails, returns HTTP 403 Unauthorized before reaching your handler methods
- No additional configuration needed - validation is handled automatically

```typescript
export class MyToolFunction extends ToolFunction {
  @tool({
    name: 'secure_tool',
    description: 'Tool that validates requests from Opal',
    endpoint: '/secure-endpoint',
    parameters: [
      { name: 'data', type: ParameterType.String, description: 'Data to process', required: true }
    ]
  })
  async secureToolHandler(
    params: { data: string }, 
    authData: OptiIdAuthData
  ) {
    
    // Process the request knowing it's from a trusted Opal instance
    return {
      status: 'success',
      data: `Processed: ${params.data}`,
      authorizedBy: 'Opal'
    };
  }
}
```

#### Custom Auth Providers

Tools can declare custom authentication providers instead of using the default OptiID. When a tool specifies its own `authRequirements`, the SDK respects that choice and skips its built-in authentication validation:

```typescript
@tool({
  name: 'external_api_tool',
  description: 'Tool that uses external OAuth2 authentication',
  endpoint: '/external-api',
  parameters: [
    { name: 'query', type: ParameterType.String, description: 'Search query', required: true }
  ],
  authRequirements: [{ provider: 'google', scopeBundle: 'calendar', required: true }]
})
async externalApiTool(params: { query: string }, authData: OAuthAuthData) {
  // SDK does NOT validate oauth2 credentials
  // Your handler must validate the auth credentials
  // Validate token with your external provider
  const isValid = await this.validateOAuth2Token(authData.credentials.access_token);
  if (!isValid) {
    throw new ToolError('Invalid OAuth2 token', 401);
  }

  return { results: await this.searchExternalApi(params.query) };
}
```

**Important Security Considerations:**

> **Warning**: Tools using non-OptiID authentication should NOT expose sensitive data from app settings (such as API keys, secrets, or organization-specific configuration).
>
> - **OptiID auth** guarantees the caller is authorized for the specific organization and the request is from a trusted Opal instance
> - **Non-OptiID auth** only guarantees the credentials are valid for that provider - it does NOT guarantee the caller is authorized for the organization or app
>
> Only credentials for the declared auth provider are validated. If your tool declares `google` auth requirement, the SDK will pass through whatever credentials Opal sends without SDK-level validation. Your handler is fully responsible for credential validation and access control.

## Error Handling

The SDK provides RFC 9457 Problem Details compliant error handling through the `ToolError` class. This allows you to throw errors with custom HTTP status codes and detailed error information.

### ToolError Class

```typescript
class ToolError extends Error {
  constructor(
    message: string,      // Error message (used as "title" in RFC 9457)
    status?: number,      // HTTP status code (default: 500)
    detail?: string       // Detailed error description (optional)
  )
}
```

### Usage Examples

```typescript
import { ToolFunction, tool, ToolError, ParameterType } from '@optimizely-opal/opal-tool-ocp-sdk';


// Throw a 404 error
throw new ToolError(
  'Task not found',
  404,
  `No task exists with ID: ${params.taskId}`
);

// Throw a 400 error for invalid input
throw new ToolError(
  'Invalid priority',
  400,
  `Priority must be one of: ${validPriorities.join(', ')}`
);
```

### Error Response Format

When a `ToolError` is thrown, the SDK automatically formats it as an RFC 9457 Problem Details response:

```json
{
  "title": "Task not found",
  "status": 404,
  "detail": "No task exists with ID: task-123",
  "instance": "/get-task"
}
```

### Validation Errors with Multiple Fields

You can also throw `ToolError` with an `errors` array for validation scenarios with multiple field errors:

```typescript
import { ToolError } from '@optimizely-opal/opal-tool-ocp-sdk';

// Validate multiple fields
const validationErrors = [];

if (!email.includes('@')) {
  validationErrors.push({
    field: 'email',
    message: 'Invalid email format'
  });
}

if (age < 0) {
  validationErrors.push({
    field: 'age',
    message: 'Age must be a positive number'
  });
}

if (validationErrors.length > 0) {
  throw new ToolError(
    'Validation failed',
    400,
    "See 'errors' field for details.",
    validationErrors
  );
}
```

This will return:

```json
{
  "title": "Validation failed",
  "status": 400,
  "detail": "See 'errors' field for details.",
  "instance": "/create-user",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email format"
    },
    {
      "field": "age",
      "message": "Age must be a positive number"
    }
  ]
}
```

**Response Headers:**
- `Content-Type: application/problem+json`
- HTTP status code matches the `ToolError` status

**Note:**
- If a regular `Error` (not `ToolError`) is thrown, it will be automatically wrapped in a 500 response with RFC 9457 format
- Parameter validation is automatic and uses this same format when validation fails

## API Reference

### Handler Function Signatures

All tool handler methods follow this signature pattern:

```typescript
async handlerMethod(
  params: TParams,           // Tool parameters
  authData: OptiIdAuthData | OAuthAuthData  // OptiID or OAuth user authentication data
): Promise<TResult>
```

- **params**: The input parameters for tools
- **authData**: OptiID user authentication by default; can be customized in tool definition


### Decorators

#### `@tool(config: ToolConfig)`

Registers a method as a discoverable tool.

```typescript
interface ToolConfig {
  name: string;
  description: string;
  parameters: ParameterConfig[];
  authRequirements?: AuthRequirementConfig[];
  endpoint: string;
  tags?: string[];  // Optional tags for categorization and filtering
}
```

**Tag-based Filtering**

The SDK supports optional tags for tools to enable/disable groups of tools dynamically through settings:

```typescript
@tool({
  name: 'get_user',
  description: 'Get user information',
  endpoint: '/get-user',
  parameters: [],
  tags: ['get_tool', 'default_tool_set']
})
async getUser(params: any, authData: OptiIdAuthData) {
  // Implementation
}
```

To enable tag-based filtering, add a `tool_tags` section to your settings configuration:

**`forms/settings.yml`**
```yaml
sections:
  - key: tool_tags
    label: Tool Configuration
    elements:
      - type: toggle
        key: get_tool
        label: Enable GET operations
        defaultValue: true
      - type: toggle
        key: default_tool_set
        label: Enable default tool set
        defaultValue: true
```

**Filtering behavior:**
- Tools with **no tags** are always enabled
- Tools with tags are enabled only if **ALL** of their tags are enabled in settings
- If any tag is disabled (set to `false`), the tool is excluded from both discovery and execution
- Tags not configured in settings default to `true` (enabled)
- If no `tool_tags` settings exist, all tools are enabled by default

### Base Classes

#### `ToolFunction`

Abstract base class for organization-scoped OCP functions:

```typescript
export abstract class ToolFunction extends Function {
  protected ready(): Promise<ReadyResponse>;
  public async perform(): Promise<Response>;
}
```

Extend this class for regular tools that validate organization IDs. The `perform` method automatically routes requests to registered tools and enforces organization validation.

#### `GlobalToolFunction`

Abstract base class for global OCP functions:

```typescript
export abstract class GlobalToolFunction extends GlobalFunction {
  protected ready(): Promise<ReadyResponse>;
  public async perform(): Promise<Response>;
}
```

Extend this class for tools that work across organizations. The `perform` method routes requests to registered tools but skips organization validation for tools.

### Models

Key model classes:

- `Tool` - Represents a registered tool (auth data is `OptiIdAuthData | OAuthAuthData`)
- `Parameter` - Defines tool parameters
- `AuthRequirement` - Defines authentication needs
- `AuthData` - Union type for authentication data (`OptiIdAuthData | OAuthAuthData`)
- `OptiIdAuthData` - OptiID specific authentication data
- `OAuthAuthData` - OAuth provider authentication data
- `ReadyResponse` - Response type for the ready method containing status and optional reason
- `ToolError` - Custom error class for RFC 9457 Problem Details error responses with configurable HTTP status codes

## Discovery and Ready Endpoints

The SDK automatically provides two important endpoints:

### Discovery Endpoint (`/discovery`)

Returns all registered tools in the proper OCP format for platform integration. The discovery endpoint returns all tools registered within the function, regardless of their individual configuration:

- **Regular Functions** (`ToolFunction`): Returns all tools with organization-scoped behavior
- **Global Functions** (`GlobalToolFunction`): Returns all tools with platform-wide behavior

All tools within a function operate in the same mode - there is no mixing of global and organization-scoped tools within a single function.

Example response for a regular function and global function:

```json
{
  "functions": [
    {
      "name": "create_task",
      "description": "Creates a new task in the system",
      "parameters": [
        {
          "name": "title",
          "type": "string",
          "description": "The task title",
          "required": true
        },
        {
          "name": "priority",
          "type": "string",
          "description": "Task priority level",
          "required": false
        }
      ],
      "endpoint": "/create-task",
      "http_method": "POST"
    },
    {
      "name": "secure_task",
      "description": "Creates a secure task with OptiID authentication",
      "parameters": [
        {
          "name": "title",
          "type": "string",
          "description": "The task title",
          "required": true
        }
      ],
      "endpoint": "/secure-task",
      "http_method": "POST",
      "auth_requirements": [
        {
          "provider": "OptiID",
          "scope_bundle": "tasks",
          "required": true
        }
      ]
    }
  ]
}
```

### Ready Endpoint (`/ready`)

Returns the current readiness status of your function:

```json
{
  "ready": true
}
```

Or when not ready with a reason:

```json
{
  "ready": false,
  "reason": "Missing API key"
}
```

This endpoint calls your function's `ready()` method and returns:
- `{ready: true}` when the function is ready to process requests
- `{ready: false, reason?: string}` when the function is not ready (missing configuration, external services unavailable, etc.), optionally with a descriptive reason
- HTTP 200 status code regardless of ready state (the ready status is in the response body)

## Development

### Prerequisites

- Node.js >= 22.0.0
- TypeScript 5.x

### Building

```bash
yarn build
```

### Testing

```bash
# Run tests
yarn test

# Run tests in watch mode
yarn test:watch

# Run tests with coverage
yarn test:coverage
```

### Linting

```bash
yarn lint
```

## Examples

### Function with Authentication

```typescript
import { ToolFunction, tool, ParameterType, OptiIdAuthData } from '@optimizely-opal/opal-ocp-sdk';

export class AuthenticatedFunction extends ToolFunction {

  // OptiID authentication example
  @tool({
    name: 'secure_operation',
    description: 'Performs a secure operation with OptiID',
    endpoint: '/secure',
    parameters: [],
    authRequirements: [{ provider: 'OptiID', scopeBundle: 'tasks', required: true }]
  })
  async secureOperation(params: unknown, authData: OptiIdAuthData) {
    if (!authData) throw new Error('OptiID authentication required');
    
    const { customer_id, access_token } = authData.credentials;
    // Use OptiID credentials for API calls
    return { success: true, customer_id };
  }

}
```

### Organizing Tools in Separate Files

For larger projects, you can organize your tools in separate files and import them into your main ToolFunction class:

**Project Structure:**
```
src/
├── tools/
│   ├── index.ts
│   ├── TaskTool.ts
│   └── NotificationTool.ts
├── MyToolFunction.ts
└── MyGlobalToolFunction.ts
```

**tools/TaskTool.ts:**
```typescript
import { tool, ParameterType, OptiIdAuthData } from '@optimizely-opal/opal-ocp-sdk';

export class TaskTool {
  @tool({
    name: 'create_task',
    description: 'Creates a new task in the system',
    endpoint: '/create-task',
    parameters: [
      {
        name: 'title',
        type: ParameterType.String,
        description: 'The task title',
        required: true
      },
      {
        name: 'priority',
        type: ParameterType.String,
        description: 'Task priority level',
        required: false
      }
    ]
  })
  async createTask(params: { title: string; priority?: string }, authData: OptiIdAuthData) {
    return {
      id: '123',
      title: params.title,
      priority: params.priority || 'medium'
    };
  }

  @tool({
    name: 'delete_task',
    description: 'Deletes a task from the system',
    endpoint: '/delete-task',
    parameters: [
      {
        name: 'taskId',
        type: ParameterType.String,
        description: 'The task ID to delete',
        required: true
      }
    ]
  })
  async deleteTask(params: { taskId: string }, authData: OptiIdAuthData) {
    return { success: true, deletedTaskId: params.taskId };
  }
}
```

**tools/NotificationTool.ts:**
```typescript
import { tool, ParameterType, OptiIdAuthData } from '@optimizely-opal/opal-ocp-sdk';

export class NotificationTool {
  @tool({
    name: 'send_notification',
    description: 'Sends a notification to users',
    endpoint: '/send-notification',
    parameters: [
      {
        name: 'message',
        type: ParameterType.String,
        description: 'The notification message',
        required: true
      },
      {
        name: 'userId',
        type: ParameterType.String,
        description: 'Target user ID',
        required: true
      }
    ]
  })
  async sendNotification(params: { message: string; userId: string }, authData: OptiIdAuthData) {
    return {
      notificationId: '456',
      message: params.message,
      userId: params.userId,
      sent: true
    };
  }

}
```

**tools/index.ts:**
```typescript
export * from './TaskTool';
export * from './NotificationTool';
```

**MyToolFunction.ts:**
```typescript
import { ToolFunction } from '@optimizely-opal/opal-ocp-sdk';
import * from './tools';

export class MyToolFunction extends ToolFunction {
}
```

This approach provides several benefits:
- **Better organization**: Each tool has its own file with related methods
- **Maintainability**: Easier to find and modify specific tools
- **Reusability**: Tools can be shared across different ToolFunction classes
- **Team collaboration**: Different developers can work on different tool files
- **Testing**: Each tool class can be unit tested independently

## Decorator Behavior and Instance Context

The `@tool` decorator provide intelligent instance context management that behaves differently depending on where the decorated methods are defined:

### ToolFunction Subclass Context

When decorators are used in a class that extends `ToolFunction`, the decorators can reuse the existing ToolFunction instance when called through the `perform()` method:

```typescript
export class MyToolFunction extends ToolFunction {
  private secretKey = process.env.SECRET_KEY;

  @tool({
    name: 'process_data',
    description: 'Processes data using instance context',
    endpoint: '/process-data',
    parameters: [
      { name: 'data', type: ParameterType.String, description: 'Data to process', required: true }
    ]
  })
  async processData(params: { data: string }) {
    // ✅ Can access instance properties and methods
    // ✅ Can access this.request (inherited from ToolFunction)
    // ✅ Shares state with other methods in the same request

    const userAgent = this.request.headers.get('user-agent');
    return {
      processedData: this.encryptData(params.data),
      userAgent,
      timestamp: Date.now()
    };
  }

  private encryptData(data: string): string {
    // Uses instance property
    return `encrypted_${data}_${this.secretKey}`;
  }
}
```

### Standalone Class Context

When decorators are used in classes that don't extend `ToolFunction`, the decorators create new instances for each handler call:

```typescript
export class StandaloneToolService {
  private config = { apiKey: 'standalone-key' };

  @tool({
    name: 'standalone_operation',
    description: 'Standalone operation without ToolFunction',
    endpoint: '/standalone',
    parameters: [
      { name: 'input', type: ParameterType.String, description: 'Input data', required: true }
    ]
  })
  async standaloneOperation(params: { input: string }) {
    // ✅ Can access instance properties and methods
    // ❌ Cannot access this.request (not inherited from ToolFunction)
    // ❌ No shared state with ToolFunction lifecycle

    return {
      result: `${this.helperMethod()}: ${params.input}`,
      source: 'standalone'
    };
  }

  private helperMethod() {
    return this.config.apiKey;
  }
}
```

This behavior ensures that your tools can be both flexible (working in any class) and powerful (leveraging ToolFunction features when available).