optimizely-opal.opal-tools-sdk 0.1.28.dev0__tar.gz → 0.1.31.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 (22) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/PKG-INFO +76 -2
  2. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/README.md +72 -0
  3. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/__init__.py +11 -3
  4. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/_registry.py +3 -1
  5. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/auth.py +8 -9
  6. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/decorators.py +101 -66
  7. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/logging.py +11 -8
  8. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/models.py +26 -19
  9. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/proteus.py +1410 -1348
  10. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/service.py +59 -34
  11. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +76 -2
  12. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +3 -1
  13. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/pyproject.toml +30 -2
  14. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/setup.py +2 -2
  15. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/tests/test_integration.py +5 -6
  16. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/tests/test_nested_schema.py +12 -9
  17. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/tests/test_proteus.py +5 -5
  18. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/opal_tools_sdk/ui.py +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.28.dev0
3
+ Version: 0.1.31.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
@@ -19,9 +19,11 @@ Requires-Dist: fastapi>=0.100.0
19
19
  Requires-Dist: pydantic>=2.0.0
20
20
  Requires-Dist: httpx>=0.24.1
21
21
  Provides-Extra: dev
22
- Requires-Dist: datamodel-code-generator>=0.25.0; extra == "dev"
22
+ Requires-Dist: datamodel-code-generator>=0.56.0; extra == "dev"
23
23
  Requires-Dist: pytest>=7.0.0; extra == "dev"
24
24
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
26
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
25
27
  Dynamic: author
26
28
  Dynamic: home-page
27
29
  Dynamic: requires-python
@@ -144,6 +146,78 @@ async def get_email(parameters: EmailParameters, auth_data: Optional[AuthData] =
144
146
  return {"emails": ["Email 1", "Email 2"]}
145
147
  ```
146
148
 
149
+ ## Interactions
150
+
151
+ Interactions are app-only handlers — actions callable from the UI but hidden from the LLM. Use them when a tool surface needs to expose a button, form submit, or other follow-up action that the model should not be able to call. They follow the MCP "app-only tools" pattern (`visibility: ["app"]`) and are not listed in the `/discovery` endpoint.
152
+
153
+ **When to use `@interaction` vs `@tool`:**
154
+
155
+ - Use `@tool` when the LLM should be able to call the function on its own.
156
+ - Use `@interaction` when only the UI (action cards, islands) should call it — the model should never see it.
157
+
158
+ ### Declaring an interaction
159
+
160
+ ```python
161
+ from typing import Optional
162
+ from opal_tools_sdk import ToolsService, interaction, InteractionContext
163
+ from pydantic import BaseModel, Field
164
+ from fastapi import FastAPI
165
+
166
+ app = FastAPI()
167
+ tools_service = ToolsService(app)
168
+
169
+
170
+ class TaskFormInput(BaseModel):
171
+ title: str = Field(description="Task title")
172
+ priority: str = Field(default="medium", description="Task priority")
173
+ assignee: Optional[str] = Field(default=None, description="Assignee email")
174
+
175
+
176
+ @interaction(
177
+ name="submit_task_form",
178
+ description="Handle task form submission",
179
+ )
180
+ async def handle_task_submission(parameters: TaskFormInput, context: InteractionContext):
181
+ # parameters is validated against TaskFormInput
182
+ # context.auth_data carries credentials when auth is configured
183
+ return {"task_id": "task-123", "message": f"Task '{parameters.title}' created"}
184
+ ```
185
+
186
+ The first parameter is introspected from the Pydantic model the same way `@tool` does it (typing the parameter as `dict` skips schema extraction). The second parameter is an `InteractionContext`.
187
+
188
+ ### Handler signature & `InteractionContext`
189
+
190
+ `InteractionContext` carries the auth data resolved by TMS from the parent tool's `auth_requirements`:
191
+
192
+ ```python
193
+ @dataclass
194
+ class InteractionContext:
195
+ auth_data: AuthData | None = None
196
+ ```
197
+
198
+ If the parent tool did not declare auth requirements (or no credentials are available), `context.auth_data` is `None`.
199
+
200
+ ### Generated endpoint
201
+
202
+ The SDK exposes a single shared endpoint for all interactions:
203
+
204
+ ```
205
+ POST /interactions/execute
206
+ Content-Type: application/json
207
+
208
+ {
209
+ "name": "submit_task_form",
210
+ "parameters": {"title": "Ship docs", "priority": "high"},
211
+ "auth": {"provider": "...", "credentials": {...}}
212
+ }
213
+ ```
214
+
215
+ The response is whatever the handler returns, serialized as JSON. Interactions are **not** listed in `/discovery`.
216
+
217
+ ### Name uniqueness
218
+
219
+ Interaction names must be unique across both tools and interactions in the same service, since both can be exported as MCP tools. Registering an interaction with a name that conflicts with an existing tool or interaction raises `ValueError`.
220
+
147
221
  ## Island Components
148
222
 
149
223
  The SDK includes Island components for creating interactive UI responses that allow users to input data and trigger actions.
@@ -116,6 +116,78 @@ async def get_email(parameters: EmailParameters, auth_data: Optional[AuthData] =
116
116
  return {"emails": ["Email 1", "Email 2"]}
117
117
  ```
118
118
 
119
+ ## Interactions
120
+
121
+ Interactions are app-only handlers — actions callable from the UI but hidden from the LLM. Use them when a tool surface needs to expose a button, form submit, or other follow-up action that the model should not be able to call. They follow the MCP "app-only tools" pattern (`visibility: ["app"]`) and are not listed in the `/discovery` endpoint.
122
+
123
+ **When to use `@interaction` vs `@tool`:**
124
+
125
+ - Use `@tool` when the LLM should be able to call the function on its own.
126
+ - Use `@interaction` when only the UI (action cards, islands) should call it — the model should never see it.
127
+
128
+ ### Declaring an interaction
129
+
130
+ ```python
131
+ from typing import Optional
132
+ from opal_tools_sdk import ToolsService, interaction, InteractionContext
133
+ from pydantic import BaseModel, Field
134
+ from fastapi import FastAPI
135
+
136
+ app = FastAPI()
137
+ tools_service = ToolsService(app)
138
+
139
+
140
+ class TaskFormInput(BaseModel):
141
+ title: str = Field(description="Task title")
142
+ priority: str = Field(default="medium", description="Task priority")
143
+ assignee: Optional[str] = Field(default=None, description="Assignee email")
144
+
145
+
146
+ @interaction(
147
+ name="submit_task_form",
148
+ description="Handle task form submission",
149
+ )
150
+ async def handle_task_submission(parameters: TaskFormInput, context: InteractionContext):
151
+ # parameters is validated against TaskFormInput
152
+ # context.auth_data carries credentials when auth is configured
153
+ return {"task_id": "task-123", "message": f"Task '{parameters.title}' created"}
154
+ ```
155
+
156
+ The first parameter is introspected from the Pydantic model the same way `@tool` does it (typing the parameter as `dict` skips schema extraction). The second parameter is an `InteractionContext`.
157
+
158
+ ### Handler signature & `InteractionContext`
159
+
160
+ `InteractionContext` carries the auth data resolved by TMS from the parent tool's `auth_requirements`:
161
+
162
+ ```python
163
+ @dataclass
164
+ class InteractionContext:
165
+ auth_data: AuthData | None = None
166
+ ```
167
+
168
+ If the parent tool did not declare auth requirements (or no credentials are available), `context.auth_data` is `None`.
169
+
170
+ ### Generated endpoint
171
+
172
+ The SDK exposes a single shared endpoint for all interactions:
173
+
174
+ ```
175
+ POST /interactions/execute
176
+ Content-Type: application/json
177
+
178
+ {
179
+ "name": "submit_task_form",
180
+ "parameters": {"title": "Ship docs", "priority": "high"},
181
+ "auth": {"provider": "...", "credentials": {...}}
182
+ }
183
+ ```
184
+
185
+ The response is whatever the handler returns, serialized as JSON. Interactions are **not** listed in `/discovery`.
186
+
187
+ ### Name uniqueness
188
+
189
+ Interaction names must be unique across both tools and interactions in the same service, since both can be exported as MCP tools. Registering an interaction with a name that conflicts with an existing tool or interaction raises `ValueError`.
190
+
119
191
  ## Island Components
120
192
 
121
193
  The SDK includes Island components for creating interactive UI responses that allow users to input data and trigger actions.
@@ -1,9 +1,17 @@
1
- from .service import ToolsService
2
- from .decorators import tool, resource, interaction
3
1
  from .auth import requires_auth
2
+ from .decorators import interaction, resource, tool
4
3
  from .logging import register_logger_factory
5
- from .models import AuthData, AuthRequirement, Credentials, Environment, InteractionContext, IslandConfig, IslandResponse
4
+ from .models import (
5
+ AuthData,
6
+ AuthRequirement,
7
+ Credentials,
8
+ Environment,
9
+ InteractionContext,
10
+ IslandConfig,
11
+ IslandResponse,
12
+ )
6
13
  from .proteus import UI
14
+ from .service import ToolsService
7
15
 
8
16
  __version__ = "0.1.14-dev"
9
17
  __all__ = [
@@ -1,3 +1,5 @@
1
1
  """Internal registry for ToolsService instances."""
2
2
 
3
- services = []
3
+ from typing import Any
4
+
5
+ services: list[Any] = []
@@ -1,7 +1,9 @@
1
- from typing import Callable, Dict, Any, Optional, List
1
+ from collections.abc import Callable
2
2
  from functools import wraps
3
+
3
4
  from fastapi import Header, HTTPException
4
5
 
6
+
5
7
  def requires_auth(provider: str, scope_bundle: str, required: bool = True):
6
8
  """Decorator to indicate that a tool requires authentication.
7
9
 
@@ -13,9 +15,10 @@ def requires_auth(provider: str, scope_bundle: str, required: bool = True):
13
15
  Returns:
14
16
  Decorator function
15
17
  """
18
+
16
19
  def decorator(func: Callable):
17
20
  @wraps(func)
18
- async def wrapper(*args, authorization: Optional[str] = Header(None), **kwargs):
21
+ async def wrapper(*args, authorization: str | None = Header(None), **kwargs):
19
22
  if required and not authorization:
20
23
  raise HTTPException(status_code=401, detail="Authentication required")
21
24
 
@@ -24,18 +27,14 @@ def requires_auth(provider: str, scope_bundle: str, required: bool = True):
24
27
  return await func(*args, authorization=authorization, **kwargs)
25
28
 
26
29
  # Store auth requirements in function metadata
27
- auth_req = {
28
- "provider": provider,
29
- "scope_bundle": scope_bundle,
30
- "required": required
31
- }
30
+ auth_req = {"provider": provider, "scope_bundle": scope_bundle, "required": required}
32
31
 
33
32
  # Initialize the list if it doesn't exist
34
33
  if not hasattr(wrapper, "__auth_requirements__"):
35
- wrapper.__auth_requirements__ = []
34
+ wrapper.__auth_requirements__ = [] # type: ignore[attr-defined]
36
35
 
37
36
  # Add this auth requirement to the list
38
- wrapper.__auth_requirements__.append(auth_req)
37
+ wrapper.__auth_requirements__.append(auth_req) # type: ignore[attr-defined]
39
38
 
40
39
  return wrapper
41
40
 
@@ -1,17 +1,20 @@
1
1
  import inspect
2
- import re
3
- import logging
4
- from typing import Callable, Any, List, Dict, Type, get_args, get_origin, get_type_hints, Optional, Union
5
- from fastapi import APIRouter, Depends, Header, HTTPException
6
- from pydantic import BaseModel
2
+ from collections.abc import Callable
3
+ from typing import (
4
+ Any,
5
+ Union,
6
+ get_args,
7
+ get_origin,
8
+ get_type_hints,
9
+ )
7
10
 
8
- from .models import Parameter, ParameterType, AuthRequirement
9
11
  from .logging import get_logger
12
+ from .models import AuthRequirement, Parameter, ParameterType
10
13
 
11
14
  logger = get_logger(__name__)
12
15
 
13
16
 
14
- def _resolve_json_schema(schema: dict, defs: dict, _seen: Optional[set] = None) -> dict:
17
+ def _resolve_json_schema(schema: dict, defs: dict, _seen: set | None = None) -> dict:
15
18
  """Resolve $ref references and simplify Pydantic v2 JSON schema output.
16
19
 
17
20
  - Resolves ``$ref`` pointers by looking up ``$defs``
@@ -40,7 +43,8 @@ def _resolve_json_schema(schema: dict, defs: dict, _seen: Optional[set] = None)
40
43
  # Simplify anyOf (Pydantic Optional encoding)
41
44
  if "anyOf" in schema:
42
45
  non_null = [
43
- s for s in schema["anyOf"]
46
+ s
47
+ for s in schema["anyOf"]
44
48
  if (s.get("type") != "null" and "$ref" not in s) or "$ref" in s
45
49
  ]
46
50
  if len(non_null) == 1:
@@ -85,11 +89,12 @@ def _resolve_json_schema(schema: dict, defs: dict, _seen: Optional[set] = None)
85
89
 
86
90
  return result
87
91
 
92
+
88
93
  def tool(
89
94
  name: str,
90
95
  description: str,
91
- auth_requirements: Optional[List[Dict[str, Any]]] = None,
92
- ui_resource: Optional[str] = None,
96
+ auth_requirements: list[dict[str, Any]] | None = None,
97
+ ui_resource: str | None = None,
93
98
  ):
94
99
  """Decorator to register a function as an Opal tool.
95
100
 
@@ -97,8 +102,10 @@ def tool(
97
102
  name: Name of the tool
98
103
  description: Description of the tool
99
104
  auth_requirements: Authentication requirements (optional)
100
- Format: [{"provider": "oauth_provider", "scope_bundle": "permissions_scope", "required": True}, ...]
101
- Example: [{"provider": "google", "scope_bundle": "calendar", "required": True}]
105
+ Format: [{"provider": "oauth_provider",
106
+ "scope_bundle": "permissions_scope", "required": True}, ...]
107
+ Example: [{"provider": "google",
108
+ "scope_bundle": "calendar", "required": True}]
102
109
  ui_resource: URI of associated UI resource for dynamic rendering (optional)
103
110
  Example: "ui://my-app/create-form"
104
111
 
@@ -113,6 +120,7 @@ def tool(
113
120
  If ui_resource is specified, the frontend can fetch the resource to render a dynamic UI
114
121
  for this tool without hardcoded integrations.
115
122
  """
123
+
116
124
  def decorator(func: Callable):
117
125
  # Get the ToolsService instance from the global registry
118
126
  from . import _registry
@@ -121,35 +129,39 @@ def tool(
121
129
  sig = inspect.signature(func)
122
130
  type_hints = get_type_hints(func)
123
131
 
124
- parameters: List[Parameter] = []
132
+ parameters: list[Parameter] = []
125
133
  param_model = None
126
134
 
127
135
  # Look for a parameter that is a pydantic model (for parameters)
128
136
  for param_name, param in sig.parameters.items():
129
137
  if param_name in type_hints:
130
138
  param_type = type_hints[param_name]
131
- if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'): # Pydantic v1 or v2
139
+ if hasattr(param_type, "__fields__") or hasattr(
140
+ param_type, "model_fields"
141
+ ): # Pydantic v1 or v2
132
142
  param_model = param_type
133
143
  break
134
144
 
135
145
  # If we found a pydantic model, extract parameters
136
146
  if param_model:
137
- model_fields = getattr(param_model, 'model_fields', getattr(param_model, '__fields__', {}))
147
+ model_fields = getattr(
148
+ param_model, "model_fields", getattr(param_model, "__fields__", {})
149
+ )
138
150
  for field_name, field in model_fields.items():
139
151
  # Get field metadata
140
- field_info = field.field_info if hasattr(field, 'field_info') else field
152
+ field_info = field.field_info if hasattr(field, "field_info") else field
141
153
 
142
154
  # Determine type
143
- if hasattr(field, 'outer_type_'):
155
+ if hasattr(field, "outer_type_"):
144
156
  field_type = field.outer_type_
145
- elif hasattr(field, 'annotation'):
157
+ elif hasattr(field, "annotation"):
146
158
  field_type = field.annotation
147
159
  else:
148
160
  field_type = str
149
161
 
150
162
  # Check if the field is Optional (Union with None)
151
163
  # Optional[X] is equivalent to Union[X, None]
152
- type_args = getattr(field_type, '__args__', ())
164
+ type_args = getattr(field_type, "__args__", ())
153
165
  is_optional = get_origin(field_type) is Union and type(None) in type_args
154
166
 
155
167
  # Extract the actual type from Optional[T]
@@ -177,9 +189,9 @@ def tool(
177
189
  items_schema = None
178
190
  if type_args_inner:
179
191
  item_type = type_args_inner[0]
180
- if hasattr(item_type, 'model_json_schema'):
192
+ if hasattr(item_type, "model_json_schema"):
181
193
  raw = item_type.model_json_schema()
182
- raw_defs = raw.pop('$defs', {})
194
+ raw_defs = raw.pop("$defs", {})
183
195
  items_schema = _resolve_json_schema(raw, raw_defs)
184
196
  elif item_type is str:
185
197
  items_schema = {"type": "string"}
@@ -193,10 +205,10 @@ def tool(
193
205
  full_schema = {"type": "array", "items": items_schema}
194
206
  elif field_type is dict or get_origin(field_type) is dict:
195
207
  param_type = ParameterType.dictionary
196
- elif hasattr(field_type, 'model_json_schema'):
208
+ elif hasattr(field_type, "model_json_schema"):
197
209
  param_type = ParameterType.dictionary
198
210
  raw = field_type.model_json_schema()
199
- raw_defs = raw.pop('$defs', {})
211
+ raw_defs = raw.pop("$defs", {})
200
212
  resolved = _resolve_json_schema(raw, raw_defs)
201
213
  full_schema = resolved
202
214
 
@@ -208,10 +220,10 @@ def tool(
208
220
  elif is_optional:
209
221
  required = False
210
222
  # Check for Pydantic v2 is_required() method
211
- elif hasattr(field_info, 'is_required'):
223
+ elif hasattr(field_info, "is_required"):
212
224
  required = field_info.is_required()
213
225
  # Fall back to checking if default is ... (Pydantic v1/v2 compatibility)
214
- elif hasattr(field_info, 'default'):
226
+ elif hasattr(field_info, "default"):
215
227
  required = field_info.default is ...
216
228
  else:
217
229
  # If no default attribute at all, assume required
@@ -222,9 +234,9 @@ def tool(
222
234
 
223
235
  # Get description
224
236
  description_text = ""
225
- if hasattr(field_info, 'description'):
237
+ if hasattr(field_info, "description"):
226
238
  description_text = field_info.description
227
- elif hasattr(field, 'description'):
239
+ elif hasattr(field, "description"):
228
240
  description_text = field.description
229
241
 
230
242
  # Build the complete schema with description included
@@ -232,16 +244,21 @@ def tool(
232
244
  if "description" not in full_schema:
233
245
  full_schema["description"] = description_text
234
246
 
235
- parameters.append(Parameter(
236
- name=field_name,
237
- param_type=param_type,
238
- description=description_text,
239
- required=required,
240
- in_context=in_context,
241
- schema=full_schema,
242
- ))
247
+ parameters.append(
248
+ Parameter(
249
+ name=field_name,
250
+ param_type=param_type,
251
+ description=description_text,
252
+ required=required,
253
+ in_context=in_context,
254
+ schema=full_schema,
255
+ )
256
+ )
243
257
 
244
- logger.info(f"Registered parameter: {field_name} of type {param_type.value}, required: {required}")
258
+ logger.info(
259
+ f"Registered parameter: {field_name} "
260
+ f"of type {param_type.value}, required: {required}"
261
+ )
245
262
  else:
246
263
  logger.warning(f"Warning: No parameter model found for {name}")
247
264
 
@@ -252,16 +269,21 @@ def tool(
252
269
  if auth_requirements:
253
270
  auth_req_list = []
254
271
  for auth_req in auth_requirements:
255
- auth_req_list.append(AuthRequirement(
256
- provider=auth_req.get("provider", ""),
257
- scope_bundle=auth_req.get("scope_bundle", ""),
258
- required=auth_req.get("required", True)
259
- ))
272
+ auth_req_list.append(
273
+ AuthRequirement(
274
+ provider=auth_req.get("provider", ""),
275
+ scope_bundle=auth_req.get("scope_bundle", ""),
276
+ required=auth_req.get("required", True),
277
+ )
278
+ )
260
279
 
261
280
  logger.info(f"Registering tool {name} with endpoint {endpoint}")
262
281
 
263
282
  if not _registry.services:
264
- logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
283
+ logger.warning(
284
+ "No services registered in registry! "
285
+ "Make sure to create ToolsService before decorating functions."
286
+ )
265
287
 
266
288
  for service in _registry.services:
267
289
  service.register_tool(
@@ -271,7 +293,7 @@ def tool(
271
293
  parameters=parameters,
272
294
  endpoint=endpoint,
273
295
  auth_requirements=auth_req_list,
274
- ui_resource=ui_resource
296
+ ui_resource=ui_resource,
275
297
  )
276
298
 
277
299
  return func
@@ -317,6 +339,7 @@ def interaction(
317
339
  task_id = await create_task(parameters.title, parameters.priority)
318
340
  return {"task_id": task_id}
319
341
  """
342
+
320
343
  def decorator(func: Callable):
321
344
  from . import _registry
322
345
 
@@ -326,29 +349,31 @@ def interaction(
326
349
  sig = inspect.signature(func)
327
350
  type_hints = get_type_hints(func)
328
351
 
329
- parameters: List[Parameter] = []
352
+ parameters: list[Parameter] = []
330
353
  param_model = None
331
354
 
332
355
  for param_name, param in sig.parameters.items():
333
356
  if param_name in type_hints:
334
357
  param_type = type_hints[param_name]
335
- if hasattr(param_type, '__fields__') or hasattr(param_type, 'model_fields'):
358
+ if hasattr(param_type, "__fields__") or hasattr(param_type, "model_fields"):
336
359
  param_model = param_type
337
360
  break
338
361
 
339
362
  if param_model:
340
- model_fields = getattr(param_model, 'model_fields', getattr(param_model, '__fields__', {}))
363
+ model_fields = getattr(
364
+ param_model, "model_fields", getattr(param_model, "__fields__", {})
365
+ )
341
366
  for field_name, field in model_fields.items():
342
- field_info = field.field_info if hasattr(field, 'field_info') else field
367
+ field_info = field.field_info if hasattr(field, "field_info") else field
343
368
 
344
- if hasattr(field, 'outer_type_'):
369
+ if hasattr(field, "outer_type_"):
345
370
  field_type = field.outer_type_
346
- elif hasattr(field, 'annotation'):
371
+ elif hasattr(field, "annotation"):
347
372
  field_type = field.annotation
348
373
  else:
349
374
  field_type = str
350
375
 
351
- type_args = getattr(field_type, '__args__', ())
376
+ type_args = getattr(field_type, "__args__", ())
352
377
  is_optional = get_origin(field_type) is Union and type(None) in type_args
353
378
 
354
379
  if is_optional and type_args:
@@ -374,28 +399,33 @@ def interaction(
374
399
  required = field_info_extra["required"]
375
400
  elif is_optional:
376
401
  required = False
377
- elif hasattr(field_info, 'is_required'):
402
+ elif hasattr(field_info, "is_required"):
378
403
  required = field_info.is_required()
379
- elif hasattr(field_info, 'default'):
404
+ elif hasattr(field_info, "default"):
380
405
  required = field_info.default is ...
381
406
  else:
382
407
  required = True
383
408
 
384
409
  description_text = ""
385
- if hasattr(field_info, 'description'):
410
+ if hasattr(field_info, "description"):
386
411
  description_text = field_info.description
387
- elif hasattr(field, 'description'):
412
+ elif hasattr(field, "description"):
388
413
  description_text = field.description
389
414
 
390
- parameters.append(Parameter(
391
- name=field_name,
392
- param_type=param_type,
393
- description=description_text,
394
- required=required,
395
- ))
415
+ parameters.append(
416
+ Parameter(
417
+ name=field_name,
418
+ param_type=param_type,
419
+ description=description_text,
420
+ required=required,
421
+ )
422
+ )
396
423
 
397
424
  if not _registry.services:
398
- logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
425
+ logger.warning(
426
+ "No services registered in registry! "
427
+ "Make sure to create ToolsService before decorating functions."
428
+ )
399
429
 
400
430
  for service in _registry.services:
401
431
  service.register_interaction(
@@ -413,9 +443,9 @@ def interaction(
413
443
  def resource(
414
444
  uri: str,
415
445
  name: str,
416
- description: Optional[str] = None,
417
- mime_type: Optional[str] = None,
418
- title: Optional[str] = None,
446
+ description: str | None = None,
447
+ mime_type: str | None = None,
448
+ title: str | None = None,
419
449
  ):
420
450
  """Decorator to register a function as an MCP resource.
421
451
 
@@ -423,7 +453,8 @@ def resource(
423
453
  uri: The unique URI for this resource (e.g., "ui://my-app/create-form")
424
454
  name: Name of the resource
425
455
  description: Description of the resource (optional)
426
- mime_type: MIME type of the resource content (optional, e.g., "application/vnd.opal.proteus+json")
456
+ mime_type: MIME type of the resource content
457
+ (optional, e.g., "application/vnd.opal.proteus+json")
427
458
  title: Human-readable title for the resource (optional)
428
459
 
429
460
  Returns:
@@ -465,6 +496,7 @@ def resource(
465
496
  actions=[UI.Action(children="Save", appearance="primary")]
466
497
  )
467
498
  """
499
+
468
500
  def decorator(func: Callable):
469
501
  # Get the ToolsService instance from the global registry
470
502
  from . import _registry
@@ -472,7 +504,10 @@ def resource(
472
504
  logger.info(f"Registering resource {name} with URI {uri}")
473
505
 
474
506
  if not _registry.services:
475
- logger.warning("No services registered in registry! Make sure to create ToolsService before decorating functions.")
507
+ logger.warning(
508
+ "No services registered in registry! "
509
+ "Make sure to create ToolsService before decorating functions."
510
+ )
476
511
 
477
512
  for service in _registry.services:
478
513
  service.register_resource(
@@ -1,12 +1,14 @@
1
1
  import logging
2
2
  import sys
3
- from typing import Optional, Callable
3
+ from collections.abc import Callable
4
+ from typing import TypeAlias
4
5
 
5
6
  # Type alias for a logger factory function
6
- type LoggerFactory = Callable[[Optional[str]], logging.Logger]
7
+ LoggerFactory: TypeAlias = Callable[[str | None], logging.Logger]
7
8
 
8
9
  # Internal variable to hold a custom logger factory
9
- _custom_logger_factory: Optional[LoggerFactory] = None
10
+ _custom_logger_factory: LoggerFactory | None = None
11
+
10
12
 
11
13
  def register_logger_factory(factory: LoggerFactory):
12
14
  """
@@ -16,9 +18,11 @@ def register_logger_factory(factory: LoggerFactory):
16
18
  global _custom_logger_factory
17
19
  _custom_logger_factory = factory
18
20
 
19
- def get_logger(name: str = None) -> logging.Logger:
20
- """
21
- Returns a logger configured to output to the console, or uses a registered custom logger factory.
21
+
22
+ def get_logger(name: str | None = None) -> logging.Logger:
23
+ """Return a logger configured for console output.
24
+
25
+ Uses a registered custom logger factory if available.
22
26
  If no name is provided, uses the root logger.
23
27
  """
24
28
  if _custom_logger_factory is not None:
@@ -27,8 +31,7 @@ def get_logger(name: str = None) -> logging.Logger:
27
31
  if not logger.handlers:
28
32
  handler = logging.StreamHandler(sys.stdout)
29
33
  formatter = logging.Formatter(
30
- fmt='%(asctime)s %(levelname)s [%(name)s] %(message)s',
31
- datefmt='%Y-%m-%d %H:%M:%S'
34
+ fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
32
35
  )
33
36
  handler.setFormatter(formatter)
34
37
  logger.addHandler(handler)