@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.
- package/README.md +201 -216
- package/dist/decorators.d.ts +2 -0
- package/dist/decorators.js +2 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -4
- package/dist/models.d.ts +22 -1
- package/dist/models.js +28 -2
- package/dist/proteus.d.ts +1587 -0
- package/dist/proteus.js +98 -0
- package/dist/registerResource.d.ts +60 -0
- package/dist/registerResource.js +59 -0
- package/dist/registerTool.d.ts +18 -7
- package/dist/registerTool.js +52 -3
- package/dist/service.d.ts +15 -3
- package/dist/service.js +80 -21
- package/package.json +2 -2
- package/scripts/generate-proteus.ts +135 -0
- package/src/decorators.ts +4 -0
- package/src/index.ts +3 -2
- package/src/models.ts +27 -0
- package/src/proteus.ts +2314 -0
- package/src/registerResource.ts +82 -0
- package/src/registerTool.ts +19 -29
- package/src/service.ts +110 -23
- package/tests/integration.test.ts +252 -73
- package/tests/proteus.test.ts +122 -0
- package/dist/block.d.ts +0 -4760
- package/dist/block.js +0 -104
- package/scripts/generate-block.ts +0 -167
- package/src/block.ts +0 -11761
- package/tests/block.test.ts +0 -115
|
@@ -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
|
+
}
|
package/src/registerTool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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", (
|
|
136
|
-
res.json({
|
|
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);
|