@optimizely-opal/opal-tools-sdk 0.1.6-dev → 0.1.9-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.
@@ -0,0 +1,82 @@
1
+ import { ProteusDocument } from "./proteus";
2
+ import { registry } from "./registry";
3
+
4
+ type ResourceOptions = {
5
+ /** Description of the resource */
6
+ description?: string;
7
+ /** MIME type of the resource content (e.g., "application/vnd.opal.proteus+json") */
8
+ mimeType?: string;
9
+ /** Human-readable title for the resource */
10
+ title?: string;
11
+ /** The unique URI for this resource (e.g., "ui://my-app/create-form") */
12
+ uri: string;
13
+ };
14
+
15
+ /**
16
+ * Register a function as an MCP resource
17
+ *
18
+ * The handler can return either a string or a UI.Document. When returning a UI.Document,
19
+ * the SDK will automatically serialize it to JSON and set the MIME type to
20
+ * "application/vnd.opal.proteus+json" (if not already specified).
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // Example 1: Returning a string (manual serialization)
25
+ * const getFormResource = registerResource('create-form', {
26
+ * uri: 'ui://my-app/create-form',
27
+ * description: 'Create form UI specification',
28
+ * mimeType: 'application/vnd.opal.proteus+json',
29
+ * title: 'Create Form'
30
+ * }, async () => {
31
+ * return JSON.stringify(proteusSpec);
32
+ * });
33
+ *
34
+ * // Example 2: Returning a UI.Document (automatic serialization)
35
+ * import { UI } from '@optimizely-opal/opal-tools-sdk';
36
+ *
37
+ * const getDynamicForm = registerResource('create-form', {
38
+ * uri: 'ui://my-app/create-form',
39
+ * description: 'Create form UI specification',
40
+ * title: 'Create Form'
41
+ * }, async () => {
42
+ * return UI.Document({
43
+ * appName: 'Item Manager',
44
+ * title: 'Create New Item',
45
+ * body: [
46
+ * UI.Heading({ children: 'Create Item' }),
47
+ * UI.Field({
48
+ * label: 'Name',
49
+ * children: UI.Input({ name: 'item_name' })
50
+ * })
51
+ * ],
52
+ * actions: [UI.Action({ children: 'Save' })]
53
+ * });
54
+ * });
55
+ * ```
56
+ *
57
+ * @param name - Name of the resource
58
+ * @param options - Resource options (uri, description, mimeType, title)
59
+ * @param handler - Async function that returns the resource content (string or UI.Document)
60
+ * @returns The handler function (for convenience)
61
+ */
62
+ export function registerResource(
63
+ name: string,
64
+ options: ResourceOptions,
65
+ handler: () => Promise<ProteusDocument | string> | ProteusDocument | string,
66
+ ): typeof handler {
67
+ console.log(`Registering resource ${name} with URI ${options.uri}`);
68
+
69
+ // Register the resource with all services
70
+ for (const service of registry.services) {
71
+ service.registerResource(
72
+ options.uri,
73
+ name,
74
+ options.description,
75
+ options.mimeType,
76
+ options.title,
77
+ handler,
78
+ );
79
+ }
80
+
81
+ return handler;
82
+ }
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
- import type { BlockResponse } from "./block";
4
-
5
3
  import {
6
4
  AuthRequirement,
7
5
  Credentials,
@@ -23,10 +21,7 @@ export type RequestHandlerExtra = {
23
21
  mode: "headless" | "interactive";
24
22
  };
25
23
 
26
- type ToolOptions<
27
- TSchema extends Record<string, z.ZodTypeAny>,
28
- TType extends "block" | "json" = "json",
29
- > = {
24
+ type ToolOptions<TSchema extends Record<string, z.ZodTypeAny>> = {
30
25
  authRequirements?: {
31
26
  provider: string;
32
27
  required?: boolean;
@@ -34,7 +29,7 @@ type ToolOptions<
34
29
  };
35
30
  description: string;
36
31
  inputSchema: TSchema;
37
- type?: TType;
32
+ uiResource?: string;
38
33
  };
39
34
 
40
35
  /**
@@ -72,36 +67,31 @@ type ToolOptions<
72
67
  * return fetch(params.url, { headers });
73
68
  * });
74
69
  * ```
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * // With UI resource for dynamic rendering
74
+ * const createTaskTool = registerTool('create_task', {
75
+ * description: 'Create a new task',
76
+ * inputSchema: {
77
+ * title: z.string().describe('Task title'),
78
+ * description: z.string().describe('Task description')
79
+ * },
80
+ * uiResource: 'ui://my-app/create-form'
81
+ * }, async (params) => {
82
+ * return { id: 'task-123', ...params };
83
+ * });
84
+ * ```
75
85
  */
76
- // Overload for block tools
77
- export function registerTool<TSchema extends Record<string, z.ZodTypeAny>>(
78
- name: string,
79
- options: ToolOptions<TSchema, "block">,
80
- handler: (
81
- params: { [K in keyof TSchema]: z.infer<TSchema[K]> },
82
- extra?: RequestHandlerExtra,
83
- ) => BlockResponse | Promise<BlockResponse>,
84
- ): typeof handler;
85
- // Overload for JSON tools (or when type is omitted)
86
- export function registerTool<TSchema extends Record<string, z.ZodTypeAny>>(
87
- name: string,
88
- options: Omit<ToolOptions<TSchema>, "type"> | ToolOptions<TSchema, "json">,
89
- handler: (
90
- params: { [K in keyof TSchema]: z.infer<TSchema[K]> },
91
- extra?: RequestHandlerExtra,
92
- ) => Promise<unknown> | unknown,
93
- ): typeof handler;
94
- // Implementation
95
86
  export function registerTool<TSchema extends Record<string, z.ZodTypeAny>>(
96
87
  name: string,
97
- options: ToolOptions<TSchema, "block" | "json">,
88
+ options: ToolOptions<TSchema>,
98
89
  handler: (
99
90
  params: { [K in keyof TSchema]: z.infer<TSchema[K]> },
100
91
  extra?: RequestHandlerExtra,
101
92
  ) => Promise<unknown> | unknown,
102
93
  ): typeof handler {
103
94
  // Register the tool with all services
104
- const responseType = options.type || "json";
105
95
  for (const service of registry.services) {
106
96
  service.registerTool(
107
97
  name,
@@ -118,8 +108,8 @@ export function registerTool<TSchema extends Record<string, z.ZodTypeAny>>(
118
108
  ),
119
109
  ]
120
110
  : undefined,
121
- responseType,
122
111
  true,
112
+ options.uiResource,
123
113
  );
124
114
  }
125
115
 
package/src/service.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import express, { Express, Request, Response, Router } from "express";
2
2
 
3
- import { isBlockResponse } from "./block";
4
- import { AuthRequirement, Function, Parameter } from "./models";
3
+ import { AuthRequirement, Function, Parameter, Resource } from "./models";
4
+ import { ProteusDocument, UI } from "./proteus";
5
5
  import { registry } from "./registry";
6
6
 
7
+ type ResourceRegistration = {
8
+ handler: () => Promise<ProteusDocument | string> | ProteusDocument | string;
9
+ metadata: Resource;
10
+ };
11
+
7
12
  export class ToolsService {
8
13
  private app: Express;
9
14
  private functions: Function[] = [];
15
+ private resources: Map<string, ResourceRegistration> = new Map();
16
+
10
17
  private router: Router;
11
18
 
12
19
  /**
@@ -22,6 +29,31 @@ export class ToolsService {
22
29
  registry.services.push(this);
23
30
  }
24
31
 
32
+ /**
33
+ * Register a resource function
34
+ * @param uri The unique URI for this resource (e.g., "ui://my-app/create-form")
35
+ * @param name Name of the resource
36
+ * @param description Description of the resource (optional)
37
+ * @param mimeType MIME type of the resource content (optional)
38
+ * @param title Human-readable title for the resource (optional)
39
+ * @param handler Function implementing the resource (returns string or ProteusDocument)
40
+ */
41
+ registerResource(
42
+ uri: string,
43
+ name: string,
44
+ description: string | undefined,
45
+ mimeType: string | undefined,
46
+ title: string | undefined,
47
+ handler: () => Promise<ProteusDocument | string> | ProteusDocument | string,
48
+ ): void {
49
+ console.log(`Registering resource: ${name} with URI: ${uri}`);
50
+
51
+ const metadata = new Resource(uri, name, description, mimeType, title);
52
+
53
+ // Store both metadata and handler together
54
+ this.resources.set(uri, { handler, metadata });
55
+ }
56
+
25
57
  /**
26
58
  * Register a tool function
27
59
  * @param name Tool name
@@ -30,8 +62,8 @@ export class ToolsService {
30
62
  * @param parameters List of parameters for the tool
31
63
  * @param endpoint API endpoint for the tool
32
64
  * @param authRequirements Authentication requirements (optional)
33
- * @param responseType Response type - 'json' (default) or 'block'
34
65
  * @param isNewStyle Whether this is a new-style tool (registerTool) vs legacy decorator
66
+ * @param uiResource URI of associated UI resource for dynamic rendering (optional)
35
67
  */
36
68
  registerTool(
37
69
  name: string,
@@ -41,8 +73,8 @@ export class ToolsService {
41
73
  parameters: Parameter[],
42
74
  endpoint: string,
43
75
  authRequirements?: AuthRequirement[],
44
- responseType: "block" | "json" = "json",
45
76
  isNewStyle: boolean = false,
77
+ uiResource?: string,
46
78
  ): void {
47
79
  const func = new Function(
48
80
  name,
@@ -50,12 +82,10 @@ export class ToolsService {
50
82
  parameters,
51
83
  endpoint,
52
84
  authRequirements,
85
+ uiResource,
53
86
  );
54
87
  this.functions.push(func);
55
88
 
56
- // Determine if this is a block tool
57
- const isBlockTool = responseType === "block";
58
-
59
89
  // Register the actual endpoint
60
90
  this.router.post(endpoint, async (req: Request, res: Response) => {
61
91
  try {
@@ -106,19 +136,7 @@ export class ToolsService {
106
136
 
107
137
  console.log(`Tool ${name} returned:`, result);
108
138
 
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
- }
139
+ res.json(result);
122
140
  } catch (error) {
123
141
  console.error(`Error in tool ${name}:`, error);
124
142
  res.status(500).json({
@@ -129,11 +147,80 @@ export class ToolsService {
129
147
  }
130
148
 
131
149
  /**
132
- * Initialize the discovery endpoint
150
+ * Initialize the discovery endpoint and resources/read endpoint
133
151
  */
134
152
  private initRoutes(): void {
135
- this.router.get("/discovery", (req: Request, res: Response) => {
136
- res.json({ functions: this.functions.map((f) => f.toJSON()) });
153
+ this.router.get("/discovery", (_req: Request, res: Response) => {
154
+ res.json({
155
+ functions: this.functions.map((f) => f.toJSON()),
156
+ });
157
+ });
158
+
159
+ // POST /resources/read endpoint for MCP protocol
160
+ this.router.post("/resources/read", async (req: Request, res: Response) => {
161
+ try {
162
+ const { uri } = req.body;
163
+
164
+ if (!uri) {
165
+ return res.status(400).json({
166
+ error: "Missing required field: uri",
167
+ });
168
+ }
169
+
170
+ console.log(`Received resource read request for URI: ${uri}`);
171
+
172
+ // Find the resource registration
173
+ const registration = this.resources.get(uri);
174
+
175
+ if (!registration) {
176
+ return res.status(404).json({
177
+ error: `Resource not found: ${uri}`,
178
+ });
179
+ }
180
+
181
+ // Call handler and await result
182
+ const content = await registration.handler();
183
+
184
+ let textContent: string;
185
+ let mimeType = registration.metadata.mimeType;
186
+
187
+ // Check if handler returned a ProteusDocument
188
+ if (
189
+ typeof content === "object" &&
190
+ content !== null &&
191
+ "$type" in content &&
192
+ content.$type === "Document"
193
+ ) {
194
+ // Auto-serialize to JSON
195
+ textContent = JSON.stringify(content);
196
+ // Auto-set MIME type if not specified
197
+ if (!mimeType) {
198
+ mimeType = UI.MIME_TYPE;
199
+ }
200
+ } else if (typeof content === "string") {
201
+ textContent = content;
202
+ } else {
203
+ throw new Error(
204
+ `Resource handler for '${uri}' must return a string or ProteusDocument, but returned ${typeof content}`,
205
+ );
206
+ }
207
+
208
+ console.log(
209
+ `Resource ${uri} returned content of length: ${textContent.length}`,
210
+ );
211
+
212
+ // Return the resource content directly
213
+ res.json({
214
+ mimeType: mimeType || "text/plain",
215
+ text: textContent,
216
+ uri,
217
+ });
218
+ } catch (error) {
219
+ console.error(`Error reading resource:`, error);
220
+ res.status(500).json({
221
+ error: error instanceof Error ? error.message : "Unknown error",
222
+ });
223
+ }
137
224
  });
138
225
 
139
226
  this.app.use(this.router);