optimizely-opal.opal-tools-sdk 0.1.21.dev0__tar.gz → 0.1.23.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.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/PKG-INFO +1 -1
  2. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/__init__.py +4 -2
  3. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/decorators.py +131 -0
  4. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/models.py +7 -0
  5. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/proteus.py +70 -46
  6. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/service.py +106 -1
  7. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
  8. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/pyproject.toml +1 -1
  9. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/setup.py +1 -1
  10. optimizely_opal_opal_tools_sdk-0.1.23.dev0/tests/test_integration.py +749 -0
  11. optimizely_opal_opal_tools_sdk-0.1.21.dev0/tests/test_integration.py +0 -352
  12. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/README.md +0 -0
  13. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/_registry.py +0 -0
  14. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/auth.py +0 -0
  15. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/logging.py +0 -0
  16. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/opal_tools_sdk/ui.py +0 -0
  17. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +0 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/setup.cfg +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.dev0}/tests/test_nested_schema.py +0 -0
  23. {optimizely_opal_opal_tools_sdk-0.1.21.dev0 → optimizely_opal_opal_tools_sdk-0.1.23.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.21.dev0
3
+ Version: 0.1.23.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-24T13:50:30+00:00
3
+ # timestamp: 2026-04-01T20:49:32+00:00
4
4
 
5
5
  from __future__ import annotations
6
6
 
@@ -15,38 +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')
25
-
26
-
27
- class ProteusEventHandler2(BaseModel):
28
- """
29
- Client-side message action
30
- """
31
-
32
- model_config = ConfigDict(
33
- extra='forbid',
34
- )
35
- message: str = Field(..., description='Message to send to LLM via sendNewMessage()')
36
-
37
-
38
- class ProteusEventHandler4(BaseModel):
39
- """
40
- Client-side component action - collects name/value pairs from a data array and sends as a message
41
- """
42
-
43
- model_config = ConfigDict(
44
- extra='forbid',
45
- )
46
- action: Literal['message-from'] = Field(default='message-from', description='The action type')
47
- path: str = Field(
48
- ..., description='JSON pointer to an array of objects with name and value fields'
49
- )
24
+ interaction: str = Field(..., description='Name of registered interaction to call')
50
25
 
51
26
 
52
27
  class Series(BaseModel):
@@ -1800,28 +1775,18 @@ class ProteusCondition7(BaseModel):
1800
1775
  )
1801
1776
 
1802
1777
 
1803
- class ProteusEventHandler3(BaseModel):
1778
+ class ProteusCondition8(BaseModel):
1804
1779
  """
1805
- Client-side component action - for downloading a URL
1780
+ Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
1806
1781
  """
1807
1782
 
1808
1783
  model_config = ConfigDict(
1809
1784
  extra='forbid',
1810
1785
  )
1811
- action: Literal['download'] = Field(default='download', description='The action type')
1812
- url: ProteusValue | str = Field(..., description='URL to download')
1813
-
1814
-
1815
- class ProteusEventHandler(
1816
- RootModel[
1817
- ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 | ProteusEventHandler4
1818
- ]
1819
- ):
1820
- root: (
1821
- ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 | ProteusEventHandler4
1822
- ) = Field(
1786
+ field_: str | float | bool | ProteusValue | None = Field(
1823
1787
  ...,
1824
- description='Handler for user interactions - a server-side tool call, client-side message, or client-side component action',
1788
+ alias='!',
1789
+ description='Falsy check - returns true if value is falsy (null, undefined, false, 0, or empty string)',
1825
1790
  )
1826
1791
 
1827
1792
 
@@ -2368,7 +2333,7 @@ class ProteusTime(BaseModel):
2368
2333
  z: SprinklePropZ | None = None
2369
2334
 
2370
2335
 
2371
- class ProteusCondition8(BaseModel):
2336
+ class ProteusCondition9(BaseModel):
2372
2337
  """
2373
2338
  Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
2374
2339
  """
@@ -2384,7 +2349,7 @@ class ProteusCondition8(BaseModel):
2384
2349
  )
2385
2350
 
2386
2351
 
2387
- class ProteusCondition9(BaseModel):
2352
+ class ProteusCondition10(BaseModel):
2388
2353
  """
2389
2354
  Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.
2390
2355
  """
@@ -2432,6 +2397,34 @@ class ProteusDocument(BaseModel):
2432
2397
  )
2433
2398
 
2434
2399
 
2400
+ class ProteusEventHandler2(BaseModel):
2401
+ """
2402
+ Client-side message action
2403
+ """
2404
+
2405
+ model_config = ConfigDict(
2406
+ extra='forbid',
2407
+ )
2408
+ message: ProteusMap | ProteusValue | str = Field(
2409
+ ...,
2410
+ description='Message to send to LLM via sendNewMessage(). Can be a string, a Value reference, or a Map expression.',
2411
+ )
2412
+
2413
+
2414
+ class ProteusEventHandler3(BaseModel):
2415
+ """
2416
+ Client-side component action - for downloading a URL
2417
+ """
2418
+
2419
+ model_config = ConfigDict(
2420
+ extra='forbid',
2421
+ )
2422
+ action: Literal['download'] = Field(default='download', description='The action type')
2423
+ url: ProteusMap | ProteusValue | str = Field(
2424
+ ..., description='URL to download, or a Map expression resolving to multiple URLs'
2425
+ )
2426
+
2427
+
2435
2428
  class ProteusAction(BaseModel):
2436
2429
  model_config = ConfigDict(
2437
2430
  extra='forbid',
@@ -2507,6 +2500,9 @@ class ProteusAction(BaseModel):
2507
2500
  textAlign: SprinklePropTextAlign | None = None
2508
2501
  textTransform: SprinklePropTextTransform | None = None
2509
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
+ )
2510
2506
  w: SprinklePropW | None = None
2511
2507
  whiteSpace: SprinklePropWhiteSpace | None = None
2512
2508
  z: SprinklePropZ | None = None
@@ -2958,6 +2954,17 @@ class ProteusCardLink(BaseModel):
2958
2954
  z: SprinklePropZ | None = None
2959
2955
 
2960
2956
 
2957
+ class ProteusConcat(BaseModel):
2958
+ model_config = ConfigDict(
2959
+ extra='forbid',
2960
+ )
2961
+ field_type: Literal['Concat'] = Field(default='Concat', alias='$type')
2962
+ children: list[ProteusNode | str | None] | None = Field(
2963
+ default=None,
2964
+ description='Array of values to concatenate into a single string. Each item is resolved and joined together.',
2965
+ )
2966
+
2967
+
2961
2968
  class ProteusField(BaseModel):
2962
2969
  model_config = ConfigDict(
2963
2970
  extra='forbid',
@@ -3636,6 +3643,7 @@ class ProteusCondition(
3636
3643
  | ProteusCondition7
3637
3644
  | ProteusCondition8
3638
3645
  | ProteusCondition9
3646
+ | ProteusCondition10
3639
3647
  ]
3640
3648
  ):
3641
3649
  root: (
@@ -3648,12 +3656,22 @@ class ProteusCondition(
3648
3656
  | ProteusCondition7
3649
3657
  | ProteusCondition8
3650
3658
  | ProteusCondition9
3659
+ | ProteusCondition10
3651
3660
  ) = Field(
3652
3661
  ...,
3653
3662
  description='Condition for Show component. Can be a comparison operator, logical AND, or logical OR. Supports nesting.',
3654
3663
  )
3655
3664
 
3656
3665
 
3666
+ class ProteusEventHandler(
3667
+ RootModel[ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3]
3668
+ ):
3669
+ root: ProteusEventHandler1 | ProteusEventHandler2 | ProteusEventHandler3 = Field(
3670
+ ...,
3671
+ description='Handler for user interactions - a server-side interaction call, client-side message, or client-side component action',
3672
+ )
3673
+
3674
+
3657
3675
  class ProteusElement(
3658
3676
  RootModel[
3659
3677
  ProteusAction
@@ -3664,6 +3682,7 @@ class ProteusElement(
3664
3682
  | ProteusCardHeader
3665
3683
  | ProteusCardLink
3666
3684
  | ProteusChart
3685
+ | ProteusConcat
3667
3686
  | ProteusDataTable
3668
3687
  | ProteusField
3669
3688
  | ProteusGroup
@@ -3696,6 +3715,7 @@ class ProteusElement(
3696
3715
  | ProteusCardHeader
3697
3716
  | ProteusCardLink
3698
3717
  | ProteusChart
3718
+ | ProteusConcat
3699
3719
  | ProteusDataTable
3700
3720
  | ProteusField
3701
3721
  | ProteusGroup
@@ -3746,9 +3766,11 @@ class ProteusNode(
3746
3766
  )
3747
3767
 
3748
3768
 
3749
- ProteusCondition8.model_rebuild()
3750
3769
  ProteusCondition9.model_rebuild()
3770
+ ProteusCondition10.model_rebuild()
3751
3771
  ProteusDocument.model_rebuild()
3772
+ ProteusEventHandler2.model_rebuild()
3773
+ ProteusEventHandler3.model_rebuild()
3752
3774
  ProteusAction.model_rebuild()
3753
3775
  ProteusAvatar.model_rebuild()
3754
3776
  ProteusBadge.model_rebuild()
@@ -3756,6 +3778,7 @@ ProteusCancelAction.model_rebuild()
3756
3778
  ProteusCard.model_rebuild()
3757
3779
  ProteusCardHeader.model_rebuild()
3758
3780
  ProteusCardLink.model_rebuild()
3781
+ ProteusConcat.model_rebuild()
3759
3782
  ProteusField.model_rebuild()
3760
3783
  ProteusGroup.model_rebuild()
3761
3784
  ProteusHeading.model_rebuild()
@@ -3789,6 +3812,7 @@ class UI:
3789
3812
  CardHeader = ProteusCardHeader
3790
3813
  CardLink = ProteusCardLink
3791
3814
  Chart = ProteusChart
3815
+ Concat = ProteusConcat
3792
3816
  DataTable = ProteusDataTable
3793
3817
  Document = ProteusDocument
3794
3818
  Field = ProteusField
@@ -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.21.dev0
3
+ Version: 0.1.23.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.21-dev"
7
+ version = "0.1.23-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.21-dev",
5
+ version="0.1.23-dev",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "fastapi>=0.100.0",