@optimizely-opal/opal-tools-sdk 0.1.3-dev → 0.1.6-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/registry.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { ToolsService } from './service';
1
+ import { ToolsService } from "./service";
2
2
 
3
3
  /**
4
4
  * Internal registry for ToolsService instances
5
5
  */
6
6
  export const registry = {
7
- services: [] as ToolsService[]
7
+ services: [] as ToolsService[],
8
8
  };
package/src/service.ts CHANGED
@@ -1,11 +1,13 @@
1
- import express, { Express, Request, Response, Router } from 'express';
2
- import { Function, AuthRequirement, Parameter } from './models';
3
- import { registry } from './registry';
1
+ import express, { Express, Request, Response, Router } from "express";
2
+
3
+ import { isBlockResponse } from "./block";
4
+ import { AuthRequirement, Function, Parameter } from "./models";
5
+ import { registry } from "./registry";
4
6
 
5
7
  export class ToolsService {
6
8
  private app: Express;
7
- private router: Router;
8
9
  private functions: Function[] = [];
10
+ private router: Router;
9
11
 
10
12
  /**
11
13
  * Initialize a new tools service
@@ -15,22 +17,11 @@ export class ToolsService {
15
17
  this.app = app;
16
18
  this.router = express.Router();
17
19
  this.initRoutes();
18
-
20
+
19
21
  // Register this service in the global registry
20
22
  registry.services.push(this);
21
23
  }
22
24
 
23
- /**
24
- * Initialize the discovery endpoint
25
- */
26
- private initRoutes(): void {
27
- this.router.get('/discovery', (req: Request, res: Response) => {
28
- res.json({ functions: this.functions.map(f => f.toJSON()) });
29
- });
30
-
31
- this.app.use(this.router);
32
- }
33
-
34
25
  /**
35
26
  * Register a tool function
36
27
  * @param name Tool name
@@ -39,23 +30,37 @@ export class ToolsService {
39
30
  * @param parameters List of parameters for the tool
40
31
  * @param endpoint API endpoint for the tool
41
32
  * @param authRequirements Authentication requirements (optional)
33
+ * @param responseType Response type - 'json' (default) or 'block'
34
+ * @param isNewStyle Whether this is a new-style tool (registerTool) vs legacy decorator
42
35
  */
43
36
  registerTool(
44
37
  name: string,
45
38
  description: string,
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
40
  handler: any, // Changed from Function to any to avoid confusion with built-in Function type
47
41
  parameters: Parameter[],
48
42
  endpoint: string,
49
- authRequirements?: AuthRequirement[]
43
+ authRequirements?: AuthRequirement[],
44
+ responseType: "block" | "json" = "json",
45
+ isNewStyle: boolean = false,
50
46
  ): void {
51
- const func = new Function(name, description, parameters, endpoint, authRequirements);
47
+ const func = new Function(
48
+ name,
49
+ description,
50
+ parameters,
51
+ endpoint,
52
+ authRequirements,
53
+ );
52
54
  this.functions.push(func);
53
-
55
+
56
+ // Determine if this is a block tool
57
+ const isBlockTool = responseType === "block";
58
+
54
59
  // Register the actual endpoint
55
60
  this.router.post(endpoint, async (req: Request, res: Response) => {
56
61
  try {
57
62
  console.log(`Received request for ${endpoint}:`, req.body);
58
-
63
+
59
64
  // Extract parameters from the request body
60
65
  let params;
61
66
  if (req.body && req.body.parameters) {
@@ -64,35 +69,73 @@ export class ToolsService {
64
69
  console.log(`Extracted parameters from 'parameters' key:`, params);
65
70
  } else {
66
71
  // Fallback for direct testing: { "name": "value" }
67
- console.log(`Warning: 'parameters' key not found in request body. Using body directly.`);
72
+ console.log(
73
+ `Warning: 'parameters' key not found in request body. Using body directly.`,
74
+ );
68
75
  params = req.body;
69
76
  }
70
-
77
+
71
78
  // Extract auth data if available
72
79
  const authData = req.body && req.body.auth;
73
80
  if (authData) {
74
- console.log(`Auth data provided for provider: ${authData.provider || 'unknown'}`);
81
+ console.log(
82
+ `Auth data provided for provider: ${authData.provider || "unknown"}`,
83
+ );
75
84
  }
76
-
77
- // Call the handler with extracted parameters and auth data
78
- // Check if handler accepts auth as third parameter
79
- const handlerParamCount = handler.length;
85
+
86
+ // Call the handler with extracted parameters
80
87
  let result;
81
-
82
- if (handlerParamCount >= 2) {
83
- // Handler accepts auth data
84
- result = await handler(params, authData);
88
+
89
+ if (isNewStyle) {
90
+ result = await handler(params, {
91
+ mode: (req.body && req.body.execution_mode) || "headless",
92
+ ...(authData && { auth: authData }),
93
+ });
85
94
  } else {
86
- // Handler doesn't accept auth data
87
- result = await handler(params);
95
+ // Check if handler accepts auth as third parameter
96
+ const handlerParamCount = handler.length;
97
+
98
+ if (handlerParamCount >= 2) {
99
+ // Handler accepts auth data
100
+ result = await handler(params, authData);
101
+ } else {
102
+ // Handler doesn't accept auth data
103
+ result = await handler(params);
104
+ }
88
105
  }
89
-
106
+
90
107
  console.log(`Tool ${name} returned:`, result);
91
- res.json(result);
92
- } catch (error: any) {
108
+
109
+ // Return with appropriate content-type header
110
+ if (isBlockTool) {
111
+ // Validate that block tools return a BlockResponse
112
+ if (!isBlockResponse(result)) {
113
+ throw new Error(
114
+ `Block tool '${name}' must return a BlockResponse object, but returned ${typeof result}`,
115
+ );
116
+ }
117
+ res.set("Content-Type", "application/vnd.opal.block+json");
118
+ res.json(result);
119
+ } else {
120
+ res.json(result);
121
+ }
122
+ } catch (error) {
93
123
  console.error(`Error in tool ${name}:`, error);
94
- res.status(500).json({ error: error.message || 'Unknown error' });
124
+ res.status(500).json({
125
+ error: error instanceof Error ? error.message : "Unknown error",
126
+ });
95
127
  }
96
128
  });
97
129
  }
130
+
131
+ /**
132
+ * Initialize the discovery endpoint
133
+ */
134
+ private initRoutes(): void {
135
+ this.router.get("/discovery", (req: Request, res: Response) => {
136
+ res.json({ functions: this.functions.map((f) => f.toJSON()) });
137
+ });
138
+
139
+ this.app.use(this.router);
140
+ }
98
141
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tests for Adaptive Block components
3
+ */
4
+
5
+ import { describe, expect, test } from "vitest";
6
+
7
+ import type { BlockResponse } from "../src";
8
+
9
+ import { Block } from "../src";
10
+
11
+ describe("Block Components", () => {
12
+ test("Block.Heading with children", () => {
13
+ const heading = Block.Heading({ children: "Test Heading" });
14
+ expect(heading.$type).toBe("Block.Heading");
15
+
16
+ const response: BlockResponse = {
17
+ content: Block.Document({ children: heading }),
18
+ };
19
+ expect(response).toEqual({
20
+ content: {
21
+ $type: "Block.Document",
22
+ children: { $type: "Block.Heading", children: "Test Heading" },
23
+ },
24
+ });
25
+ });
26
+
27
+ test("Block.Text with children", () => {
28
+ const text = Block.Text({ children: "Test text content" });
29
+ expect(text.$type).toBe("Block.Text");
30
+
31
+ const response: BlockResponse = {
32
+ content: Block.Document({ children: text }),
33
+ };
34
+ expect(response).toEqual({
35
+ content: {
36
+ $type: "Block.Document",
37
+ children: { $type: "Block.Text", children: "Test text content" },
38
+ },
39
+ });
40
+ });
41
+
42
+ test("Block.Heading with additional properties", () => {
43
+ const heading = Block.Heading({
44
+ children: "Styled Heading",
45
+ fontSize: "md",
46
+ fontWeight: "600",
47
+ level: "2",
48
+ });
49
+
50
+ const response: BlockResponse = {
51
+ content: Block.Document({ children: heading }),
52
+ };
53
+ expect(response).toEqual({
54
+ content: {
55
+ $type: "Block.Document",
56
+ children: {
57
+ $type: "Block.Heading",
58
+ children: "Styled Heading",
59
+ fontSize: "md",
60
+ fontWeight: "600",
61
+ level: "2",
62
+ },
63
+ },
64
+ });
65
+ });
66
+
67
+ test("Block.Group with children array", () => {
68
+ const group = Block.Group({
69
+ children: [
70
+ Block.Text({ children: "First item" }),
71
+ Block.Text({ children: "Second item" }),
72
+ ],
73
+ flexDirection: "column",
74
+ gap: "16",
75
+ });
76
+
77
+ const response: BlockResponse = {
78
+ content: Block.Document({ children: group }),
79
+ };
80
+ expect(response).toEqual({
81
+ content: {
82
+ $type: "Block.Document",
83
+ children: {
84
+ $type: "Block.Group",
85
+ children: [
86
+ { $type: "Block.Text", children: "First item" },
87
+ { $type: "Block.Text", children: "Second item" },
88
+ ],
89
+ flexDirection: "column",
90
+ gap: "16",
91
+ },
92
+ },
93
+ });
94
+ });
95
+
96
+ test("Block.Document", () => {
97
+ const doc = Block.Document({
98
+ children: [
99
+ Block.Heading({ children: "Title", level: "2" }),
100
+ Block.Text({ children: "Description" }),
101
+ ],
102
+ });
103
+
104
+ const response: BlockResponse = { content: doc };
105
+ expect(response).toEqual({
106
+ content: {
107
+ $type: "Block.Document",
108
+ children: [
109
+ { $type: "Block.Heading", children: "Title", level: "2" },
110
+ { $type: "Block.Text", children: "Description" },
111
+ ],
112
+ },
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Tests for Express integration with the registerTool() function
3
+ */
4
+
5
+ import express from "express";
6
+ import request from "supertest";
7
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
+ import { z } from "zod/v4";
9
+
10
+ import { Block, ParameterType, registerTool, tool, ToolsService } from "../src";
11
+ import { registry } from "../src/registry";
12
+
13
+ // Clear registry before each test
14
+ beforeEach(() => {
15
+ registry.services = [];
16
+ });
17
+
18
+ afterEach(() => {
19
+ registry.services = [];
20
+ });
21
+
22
+ describe("Integration Tests", () => {
23
+ test("simple tool endpoint", async () => {
24
+ const app = express();
25
+ app.use(express.json());
26
+ new ToolsService(app);
27
+
28
+ registerTool(
29
+ "greet",
30
+ {
31
+ description: "Greet a user",
32
+ inputSchema: {
33
+ name: z.string().describe("The name to greet"),
34
+ },
35
+ },
36
+ async (params) => {
37
+ return `Hello, ${params.name}!`;
38
+ },
39
+ );
40
+
41
+ // Call the endpoint
42
+ const response = await request(app)
43
+ .post("/tools/greet")
44
+ .send({ parameters: { name: "Alice" } });
45
+
46
+ expect(response.status).toBe(200);
47
+ // Verify correct content-type header for regular tools
48
+ expect(response.headers["content-type"]).toMatch(/application\/json/);
49
+ expect(response.body).toBe("Hello, Alice!");
50
+ });
51
+
52
+ test("block tool endpoint correctly serializes all fields", async () => {
53
+ const app = express();
54
+ app.use(express.json());
55
+ new ToolsService(app);
56
+
57
+ registerTool(
58
+ "create_task",
59
+ {
60
+ description: "Create a task",
61
+ inputSchema: {
62
+ name: z.string().describe("Task name"),
63
+ },
64
+ type: "block",
65
+ },
66
+ async (params) => {
67
+ return {
68
+ artifact: {
69
+ data: { name: params.name },
70
+ id: "task-123",
71
+ type: "task",
72
+ },
73
+ content: Block.Document({
74
+ children: Block.Text({ children: `Created task: ${params.name}` }),
75
+ }),
76
+ data: { created_at: "2024-01-01T00:00:00Z" },
77
+ rollback: {
78
+ config: { endpoint: "/api/tasks/task-123", method: "DELETE" },
79
+ label: "Delete Task",
80
+ type: "endpoint",
81
+ },
82
+ };
83
+ },
84
+ );
85
+
86
+ const response = await request(app)
87
+ .post("/tools/create-task")
88
+ .send({ parameters: { name: "My Task" } });
89
+
90
+ expect(response.status).toBe(200);
91
+ expect(response.headers["content-type"]).toBe(
92
+ "application/vnd.opal.block+json; charset=utf-8",
93
+ );
94
+ expect(response.body).toEqual({
95
+ artifact: { data: { name: "My Task" }, id: "task-123", type: "task" },
96
+ content: {
97
+ $type: "Block.Document",
98
+ children: { $type: "Block.Text", children: "Created task: My Task" },
99
+ },
100
+ data: { created_at: "2024-01-01T00:00:00Z" },
101
+ rollback: {
102
+ config: { endpoint: "/api/tasks/task-123", method: "DELETE" },
103
+ label: "Delete Task",
104
+ type: "endpoint",
105
+ },
106
+ });
107
+ });
108
+
109
+ test("environment parameter is correctly passed to tool", async () => {
110
+ const app = express();
111
+ app.use(express.json());
112
+ new ToolsService(app);
113
+
114
+ registerTool(
115
+ "check_env",
116
+ {
117
+ description: "Check environment",
118
+ inputSchema: {
119
+ name: z.string().describe("Name parameter"),
120
+ },
121
+ },
122
+ async (params, extra) => {
123
+ return {
124
+ has_environment: extra !== undefined,
125
+ param_name: params.name,
126
+ };
127
+ },
128
+ );
129
+
130
+ const response = await request(app)
131
+ .post("/tools/check-env")
132
+ .send({ parameters: { name: "Test" } });
133
+
134
+ expect(response.status).toBe(200);
135
+ expect(response.body).toEqual({
136
+ has_environment: true,
137
+ param_name: "Test",
138
+ });
139
+ });
140
+
141
+ test("invalid parameters return proper error", async () => {
142
+ const app = express();
143
+ app.use(express.json());
144
+ new ToolsService(app);
145
+
146
+ registerTool(
147
+ "validate_params",
148
+ {
149
+ description: "Validate parameters",
150
+ inputSchema: {
151
+ name: z.string().describe("Required name"),
152
+ },
153
+ },
154
+ async (params) => {
155
+ if (!params.name) {
156
+ throw new Error("Name is required");
157
+ }
158
+ return `Valid: ${params.name}`;
159
+ },
160
+ );
161
+
162
+ // Call with missing required parameter
163
+ const response = await request(app)
164
+ .post("/tools/validate-params")
165
+ .send({ parameters: {} });
166
+
167
+ expect(response.status).toBe(500);
168
+ expect(response.body).toHaveProperty("error");
169
+ });
170
+
171
+ test("multiple tools on same service and discovery endpoint", async () => {
172
+ const app = express();
173
+ app.use(express.json());
174
+ new ToolsService(app);
175
+
176
+ // Use legacy @tool decorator for first tool by calling it as a function
177
+ const getWeatherHandler = async () => {
178
+ return { condition: "sunny", temperature: 22 };
179
+ };
180
+
181
+ tool({
182
+ description: "Gets current weather for a location",
183
+ name: "get_weather",
184
+ parameters: [
185
+ {
186
+ description: "City name or location",
187
+ name: "location",
188
+ required: true,
189
+ type: ParameterType.String,
190
+ },
191
+ {
192
+ description: "Temperature units",
193
+ name: "units",
194
+ required: false,
195
+ type: ParameterType.String,
196
+ },
197
+ ],
198
+ })(getWeatherHandler);
199
+
200
+ // Use modern registerTool for second tool
201
+ registerTool(
202
+ "create_task",
203
+ {
204
+ authRequirements: {
205
+ provider: "google",
206
+ required: true,
207
+ scopeBundle: "tasks",
208
+ },
209
+ description: "Create a new task",
210
+ inputSchema: {
211
+ title: z.string().describe("Task title"),
212
+ },
213
+ type: "block",
214
+ },
215
+ async (params) => {
216
+ return {
217
+ content: Block.Document({
218
+ children: Block.Text({ children: `Task: ${params.title}` }),
219
+ }),
220
+ };
221
+ },
222
+ );
223
+
224
+ // Test both tool endpoints work
225
+ const weatherResponse = await request(app)
226
+ .post("/tools/get-weather")
227
+ .send({ parameters: { location: "San Francisco" } });
228
+ const taskResponse = await request(app)
229
+ .post("/tools/create-task")
230
+ .send({ parameters: { title: "My Task" } });
231
+
232
+ expect(weatherResponse.status).toBe(200);
233
+ expect(taskResponse.status).toBe(200);
234
+
235
+ // Test discovery endpoint
236
+ const discoveryResponse = await request(app).get("/discovery");
237
+
238
+ expect(discoveryResponse.status).toBe(200);
239
+ expect(discoveryResponse.headers["content-type"]).toMatch(
240
+ /application\/json/,
241
+ );
242
+ expect(discoveryResponse.body).toEqual({
243
+ functions: [
244
+ {
245
+ description: "Gets current weather for a location",
246
+ endpoint: "/tools/get-weather",
247
+ http_method: "POST",
248
+ name: "get_weather",
249
+ parameters: [
250
+ {
251
+ description: "City name or location",
252
+ name: "location",
253
+ required: true,
254
+ type: "string",
255
+ },
256
+ {
257
+ description: "Temperature units",
258
+ name: "units",
259
+ required: false,
260
+ type: "string",
261
+ },
262
+ ],
263
+ },
264
+ {
265
+ auth_requirements: [
266
+ {
267
+ provider: "google",
268
+ required: true,
269
+ scope_bundle: "tasks",
270
+ },
271
+ ],
272
+ description: "Create a new task",
273
+ endpoint: "/tools/create-task",
274
+ http_method: "POST",
275
+ name: "create_task",
276
+ parameters: [
277
+ {
278
+ description: "Task title",
279
+ name: "title",
280
+ required: true,
281
+ type: "string",
282
+ },
283
+ ],
284
+ },
285
+ ],
286
+ });
287
+ });
288
+
289
+ test("block tool without BlockResponse throws error", async () => {
290
+ const app = express();
291
+ app.use(express.json());
292
+ new ToolsService(app);
293
+
294
+ // @ts-expect-error - Testing runtime validation of invalid block tool return type
295
+ registerTool(
296
+ "invalid_block",
297
+ {
298
+ description: "Block tool that returns wrong type",
299
+ inputSchema: {
300
+ name: z.string().describe("Name"),
301
+ },
302
+ type: "block",
303
+ },
304
+ async (params) => {
305
+ // This should fail - block tools must return BlockResponse
306
+ return `This should fail: ${params.name}`;
307
+ },
308
+ );
309
+
310
+ const response = await request(app)
311
+ .post("/tools/invalid-block")
312
+ .send({ parameters: { name: "Test" } });
313
+
314
+ // Should return 500 error because block tools must return BlockResponse
315
+ expect(response.status).toBe(500);
316
+ expect(response.body.error).toContain("must return a BlockResponse object");
317
+ });
318
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["src"],
4
+ "exclude": ["node_modules", "**/*.test.ts"]
5
+ }
package/tsconfig.json CHANGED
@@ -11,6 +11,6 @@
11
11
  "experimentalDecorators": true,
12
12
  "emitDecoratorMetadata": true
13
13
  },
14
- "include": ["src"],
15
- "exclude": ["node_modules", "**/*.test.ts"]
16
- }
14
+ "include": ["scripts", "src", "tests"],
15
+ "exclude": ["node_modules"]
16
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ },
7
+ });