optimizely-opal.opal-tools-sdk 0.1.22.dev0__tar.gz → 0.1.24.dev0__tar.gz

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.
Files changed (23) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/PKG-INFO +1 -1
  2. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/__init__.py +4 -2
  3. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/decorators.py +131 -0
  4. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/models.py +7 -0
  5. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/proteus.py +10 -4
  6. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/service.py +106 -1
  7. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
  8. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/pyproject.toml +1 -1
  9. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/setup.py +1 -1
  10. optimizely_opal_opal_tools_sdk-0.1.24.dev0/tests/test_integration.py +749 -0
  11. optimizely_opal_opal_tools_sdk-0.1.22.dev0/tests/test_integration.py +0 -352
  12. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/README.md +0 -0
  13. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/_registry.py +0 -0
  14. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/auth.py +0 -0
  15. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/logging.py +0 -0
  16. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/opal_tools_sdk/ui.py +0 -0
  17. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +0 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/setup.cfg +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/tests/test_nested_schema.py +0 -0
  23. {optimizely_opal_opal_tools_sdk-0.1.22.dev0 → optimizely_opal_opal_tools_sdk-0.1.24.dev0}/tests/test_proteus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.22.dev0
3
+ Version: 0.1.24.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -1,8 +1,8 @@
1
1
  from .service import ToolsService
2
- from .decorators import tool, resource
2
+ from .decorators import tool, resource, interaction
3
3
  from .auth import requires_auth
4
4
  from .logging import register_logger_factory
5
- from .models import AuthData, AuthRequirement, Credentials, Environment, IslandConfig, IslandResponse
5
+ from .models import AuthData, AuthRequirement, Credentials, Environment, InteractionContext, IslandConfig, IslandResponse
6
6
  from .proteus import UI
7
7
 
8
8
  __version__ = "0.1.14-dev"
@@ -10,6 +10,7 @@ __all__ = [
10
10
  "ToolsService",
11
11
  "tool",
12
12
  "resource",
13
+ "interaction",
13
14
  "requires_auth",
14
15
  "register_logger_factory",
15
16
  # Models
@@ -17,6 +18,7 @@ __all__ = [
17
18
  "AuthRequirement",
18
19
  "Credentials",
19
20
  "Environment",
21
+ "InteractionContext",
20
22
  "IslandConfig",
21
23
  "IslandResponse",
22
24
  # UI
@@ -279,6 +279,137 @@ def tool(
279
279
  return decorator
280
280
 
281
281
 
282
+ def interaction(
283
+ name: str,
284
+ description: str = "",
285
+ ):
286
+ """Decorator to register a function as an interaction handler.
287
+
288
+ Interactions are app-only handlers — actions callable by the UI but hidden from the model.
289
+ They follow the MCP app-only tools pattern (visibility: ["app"]).
290
+
291
+ Args:
292
+ name: Name of the interaction. Must be unique across both tools and interactions
293
+ within the same service, since both may be exported as MCP tools.
294
+ description: Description of the interaction
295
+
296
+ Returns:
297
+ Decorator function
298
+
299
+ Note:
300
+ The handler's first parameter can be a Pydantic model defining the interaction's
301
+ input schema — the same pattern as @tool. The decorator introspects the model fields
302
+ to extract parameter definitions (type, description, required). If the first parameter
303
+ is typed as `dict`, no schema extraction is performed.
304
+
305
+ Example:
306
+ class TaskFormInput(BaseModel):
307
+ title: str = Field(description="Task title")
308
+ priority: str = Field(default="medium", description="Task priority")
309
+ assignee: Optional[str] = Field(default=None, description="Assignee email")
310
+
311
+ @interaction(
312
+ name="submit_task_form",
313
+ description="Handle task form submission"
314
+ )
315
+ async def handle_task_submission(parameters: TaskFormInput, context: InteractionContext):
316
+ # parameters is already validated and typed
317
+ task_id = await create_task(parameters.title, parameters.priority)
318
+ return {"task_id": task_id}
319
+ """
320
+ def decorator(func: Callable):
321
+ from . import _registry
322
+
323
+ logger.info(f"Registering interaction {name}")
324
+
325
+ # Extract parameters from Pydantic model in function signature (same as @tool)
326
+ sig = inspect.signature(func)
327
+ type_hints = get_type_hints(func)
328
+
329
+ parameters: List[Parameter] = []
330
+ param_model = None
331
+
332
+ for param_name, param in sig.parameters.items():
333
+ if param_name in type_hints:
334
+ param_type = type_hints[param_name]
335
+ if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
336
+ param_model = param_type
337
+ break
338
+
339
+ if param_model:
340
+ model_fields = getattr(param_model, 'model_fields', getattr(param_model, '__fields__', {}))
341
+ for field_name, field in model_fields.items():
342
+ field_info = field.field_info if hasattr(field, 'field_info') else field
343
+
344
+ if hasattr(field, 'outer_type_'):
345
+ field_type = field.outer_type_
346
+ elif hasattr(field, 'annotation'):
347
+ field_type = field.annotation
348
+ else:
349
+ field_type = str
350
+
351
+ type_args = getattr(field_type, '__args__', ())
352
+ is_optional = get_origin(field_type) is Union and type(None) in type_args
353
+
354
+ if is_optional and type_args:
355
+ field_type = next(
356
+ (arg for arg in type_args if arg is not type(None)),
357
+ field_type,
358
+ )
359
+
360
+ param_type = ParameterType.string
361
+ if field_type is int:
362
+ param_type = ParameterType.integer
363
+ elif field_type is float:
364
+ param_type = ParameterType.number
365
+ elif field_type is bool:
366
+ param_type = ParameterType.boolean
367
+ elif field_type is list or get_origin(field_type) is list:
368
+ param_type = ParameterType.list
369
+ elif field_type is dict or get_origin(field_type) is dict:
370
+ param_type = ParameterType.dictionary
371
+
372
+ field_info_extra = getattr(field_info, "json_schema_extra") or {}
373
+ if "required" in field_info_extra:
374
+ required = field_info_extra["required"]
375
+ elif is_optional:
376
+ required = False
377
+ elif hasattr(field_info, 'is_required'):
378
+ required = field_info.is_required()
379
+ elif hasattr(field_info, 'default'):
380
+ required = field_info.default is ...
381
+ else:
382
+ required = True
383
+
384
+ description_text = ""
385
+ if hasattr(field_info, 'description'):
386
+ description_text = field_info.description
387
+ elif hasattr(field, 'description'):
388
+ description_text = field.description
389
+
390
+ parameters.append(Parameter(
391
+ name=field_name,
392
+ param_type=param_type,
393
+ description=description_text,
394
+ required=required,
395
+ ))
396
+
397
+ if not _registry.services:
398
+ logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
399
+
400
+ for service in _registry.services:
401
+ service.register_interaction(
402
+ name=name,
403
+ description=description,
404
+ handler=func,
405
+ parameters=parameters,
406
+ )
407
+
408
+ return func
409
+
410
+ return decorator
411
+
412
+
282
413
  def resource(
283
414
  uri: str,
284
415
  name: str,
@@ -71,6 +71,13 @@ class AuthData(TypedDict):
71
71
  credentials: Credentials
72
72
 
73
73
 
74
+ @dataclass
75
+ class InteractionContext:
76
+ """Context object passed to interaction handlers."""
77
+
78
+ auth_data: Optional[AuthData] = None
79
+
80
+
74
81
  class Environment(TypedDict):
75
82
  """Execution environment for an Opal tool. Interactive will provide interaction islands, while headless will not."""
76
83
 
@@ -1,6 +1,6 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: proteus-document-spec.json
3
- # timestamp: 2026-03-25T19:30:58+00:00
3
+ # timestamp: 2026-04-05T18:25:55+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
 
@@ -15,13 +15,13 @@ class OpalProteusDocumentSpecification(RootModel[Any]):
15
15
 
16
16
  class ProteusEventHandler1(BaseModel):
17
17
  """
18
- Server-side tool call
18
+ Server-side interaction call
19
19
  """
20
20
 
21
21
  model_config = ConfigDict(
22
22
  extra='forbid',
23
23
  )
24
- tool: str = Field(..., description='Name of registered tool to call')
24
+ interaction: str = Field(..., description='Name of registered interaction to call')
25
25
 
26
26
 
27
27
  class Series(BaseModel):
@@ -2500,6 +2500,9 @@ class ProteusAction(BaseModel):
2500
2500
  textAlign: SprinklePropTextAlign | None = None
2501
2501
  textTransform: SprinklePropTextTransform | None = None
2502
2502
  transition: SprinklePropTransition | None = None
2503
+ type: Literal['button'] | Literal['reset'] | Literal['submit'] | ProteusValue | None = Field(
2504
+ default=None, description='The default behavior of the button.'
2505
+ )
2503
2506
  w: SprinklePropW | None = None
2504
2507
  whiteSpace: SprinklePropWhiteSpace | None = None
2505
2508
  z: SprinklePropZ | None = None
@@ -3212,6 +3215,9 @@ class ProteusInput(BaseModel):
3212
3215
  appearance: Literal['number'] | Literal['default'] | ProteusValue | None = Field(
3213
3216
  default=None, description='Control the appearance of the input.'
3214
3217
  )
3218
+ autoFocus: bool | ProteusValue | None = Field(
3219
+ default=None, description='Whether the input should be focused on mount.'
3220
+ )
3215
3221
  backgroundImage: SprinklePropBackgroundImage | None = None
3216
3222
  bg: SprinklePropBg | None = None
3217
3223
  border: SprinklePropBorder | None = None
@@ -3665,7 +3671,7 @@ class ProteusEventHandler(
3665
3671
  ):
3666
3672
  root: ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 = Field(
3667
3673
  ...,
3668
- description='Handler for user interactions - a server-side tool call, client-side message, or client-side component action',
3674
+ description='Handler for user interactions - a server-side interaction call, client-side message, or client-side component action',
3669
3675
  )
3670
3676
 
3671
3677
 
@@ -6,7 +6,7 @@ from fastapi import FastAPI, APIRouter, HTTPException, Request
6
6
  from fastapi.routing import APIRoute
7
7
  from pydantic import BaseModel, ValidationError
8
8
 
9
- from .models import Function, Parameter, AuthRequirement
9
+ from .models import Function, Parameter, AuthRequirement, InteractionContext
10
10
  from .proteus import ProteusDocument, UI
11
11
  from . import _registry
12
12
 
@@ -25,6 +25,7 @@ class ToolsService:
25
25
  self.app = app
26
26
  self.router = APIRouter()
27
27
  self.functions: List[Function] = []
28
+ self.interactions: Dict[str, Dict[str, Any]] = {} # name -> {"handler": fn, "description": str, "parameters": List[Parameter]}
28
29
  self.resource_handlers: Dict[str, Tuple[Callable, Optional[str]]] = {} # URI -> (handler, mime_type)
29
30
  self._init_routes()
30
31
 
@@ -98,6 +99,67 @@ class ToolsService:
98
99
  logger.error(traceback.format_exc())
99
100
  raise HTTPException(status_code=500, detail=str(e))
100
101
 
102
+ @self.router.post("/interactions/execute")
103
+ async def execute_interaction(request: Request):
104
+ """Execute an interaction by name with parameters."""
105
+ try:
106
+ body = await request.json()
107
+ name = body.get("name")
108
+ parameters = body.get("parameters", {})
109
+
110
+ if not name:
111
+ raise HTTPException(status_code=400, detail="Missing 'name' field in request body")
112
+
113
+ if name not in self.interactions:
114
+ raise HTTPException(status_code=404, detail=f"Interaction '{name}' not found")
115
+
116
+ interaction_info = self.interactions[name]
117
+ handler = interaction_info["handler"]
118
+
119
+ # Extract auth data if available
120
+ auth_data = body.get("auth")
121
+
122
+ # Build context object
123
+ context = InteractionContext(
124
+ auth_data=auth_data,
125
+ )
126
+
127
+ # Introspect handler signature for Pydantic model (same pattern as tool endpoint)
128
+ sig = inspect.signature(handler)
129
+ type_hints = get_type_hints(handler)
130
+
131
+ param_model = None
132
+ for param_name, param in sig.parameters.items():
133
+ if param_name in type_hints:
134
+ param_type = type_hints[param_name]
135
+ if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
136
+ param_model = param_type
137
+ break
138
+
139
+ try:
140
+ if param_model:
141
+ # Validate and deserialize parameters using the Pydantic model
142
+ model_instance = param_model(**parameters)
143
+ result = await handler(model_instance, context)
144
+ else:
145
+ # Fall back to raw dict
146
+ result = await handler(parameters, context)
147
+ return result
148
+ except ValidationError as e:
149
+ logger.warning(f"Invalid parameters for interaction '{name}': {str(e)}")
150
+ raise HTTPException(status_code=400, detail=str(e))
151
+ except Exception as e:
152
+ logger.error(f"Interaction '{name}' failed: {e}", exc_info=True)
153
+ raise HTTPException(status_code=500, detail=str(e))
154
+
155
+ except HTTPException:
156
+ raise
157
+ except Exception as e:
158
+ import traceback
159
+ logger.error(f"Error executing interaction: {str(e)}")
160
+ logger.error(traceback.format_exc())
161
+ raise HTTPException(status_code=500, detail=str(e))
162
+
101
163
  # Include router in app
102
164
  self.app.include_router(self.router)
103
165
 
@@ -148,6 +210,13 @@ class ToolsService:
148
210
  """
149
211
  logger.info(f"Registering tool: {name} with endpoint: {endpoint}")
150
212
 
213
+ # Validate name uniqueness across tools and interactions
214
+ if name in self.interactions:
215
+ raise ValueError(
216
+ f"Tool name '{name}' conflicts with an existing interaction name. "
217
+ "Tool and interaction names must be unique because both are exported as MCP tools."
218
+ )
219
+
151
220
  # Extract auth requirements from handler if decorated with @requires_auth
152
221
  handler_auth_requirements = self._extract_auth_requirements(handler)
153
222
 
@@ -263,3 +332,39 @@ class ToolsService:
263
332
 
264
333
  # Store the handler and mime_type as a tuple
265
334
  self.resource_handlers[uri] = (handler, mime_type)
335
+
336
+ def register_interaction(
337
+ self,
338
+ name: str,
339
+ description: str,
340
+ handler: Callable,
341
+ parameters: Optional[List[Parameter]] = None,
342
+ ) -> None:
343
+ """Register an interaction handler.
344
+
345
+ Args:
346
+ name: Name of the interaction. Must be unique across both tools and interactions.
347
+ description: Description of the interaction
348
+ handler: Async function that handles the interaction.
349
+ Signature: async def handler(parameters: Model|dict, context: InteractionContext) -> dict
350
+ parameters: Parameter definitions extracted from the handler's Pydantic model
351
+
352
+ Raises:
353
+ ValueError: If the name conflicts with an existing tool or interaction name.
354
+ """
355
+ # Validate name uniqueness across tools and interactions
356
+ tool_names = {f.name for f in self.functions}
357
+ if name in tool_names:
358
+ raise ValueError(
359
+ f"Interaction name '{name}' conflicts with an existing tool name. "
360
+ "Tool and interaction names must be unique because both are exported as MCP tools."
361
+ )
362
+ if name in self.interactions:
363
+ raise ValueError(f"Interaction '{name}' is already registered.")
364
+
365
+ self.interactions[name] = {
366
+ "handler": handler,
367
+ "description": description,
368
+ "parameters": parameters or [],
369
+ }
370
+ logger.info(f"Registered interaction: {name}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.22.dev0
3
+ Version: 0.1.24.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optimizely-opal.opal-tools-sdk"
7
- version = "0.1.22-dev"
7
+ version = "0.1.24-dev"
8
8
  description = "SDK for creating Opal-compatible tools services"
9
9
  authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
10
10
  readme = "README.md"
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="optimizely-opal.opal-tools-sdk",
5
- version="0.1.22-dev",
5
+ version="0.1.24-dev",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "fastapi>=0.100.0",