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.
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/PKG-INFO +76 -2
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/README.md +72 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/pyproject.toml +30 -2
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/setup.py +2 -2
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/tests/test_integration.py +5 -6
- {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
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/tests/test_proteus.py +5 -5
- {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
- {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
- {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
- {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
- {optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/setup.cfg +0 -0
{optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optimizely-opal.opal-tools-sdk
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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.
|
{optimizely_opal_opal_tools_sdk-0.1.28.dev0 → optimizely_opal_opal_tools_sdk-0.1.31.dev0}/README.md
RENAMED
|
@@ -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
|
|
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,7 +1,9 @@
|
|
|
1
|
-
from
|
|
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:
|
|
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
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
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
|
|
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:
|
|
92
|
-
ui_resource:
|
|
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",
|
|
101
|
-
|
|
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:
|
|
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,
|
|
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(
|
|
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,
|
|
152
|
+
field_info = field.field_info if hasattr(field, "field_info") else field
|
|
141
153
|
|
|
142
154
|
# Determine type
|
|
143
|
-
if hasattr(field,
|
|
155
|
+
if hasattr(field, "outer_type_"):
|
|
144
156
|
field_type = field.outer_type_
|
|
145
|
-
elif hasattr(field,
|
|
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,
|
|
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,
|
|
192
|
+
if hasattr(item_type, "model_json_schema"):
|
|
181
193
|
raw = item_type.model_json_schema()
|
|
182
|
-
raw_defs = raw.pop(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
237
|
+
if hasattr(field_info, "description"):
|
|
226
238
|
description_text = field_info.description
|
|
227
|
-
elif hasattr(field,
|
|
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(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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(
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
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(
|
|
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,
|
|
367
|
+
field_info = field.field_info if hasattr(field, "field_info") else field
|
|
343
368
|
|
|
344
|
-
if hasattr(field,
|
|
369
|
+
if hasattr(field, "outer_type_"):
|
|
345
370
|
field_type = field.outer_type_
|
|
346
|
-
elif hasattr(field,
|
|
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,
|
|
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,
|
|
402
|
+
elif hasattr(field_info, "is_required"):
|
|
378
403
|
required = field_info.is_required()
|
|
379
|
-
elif hasattr(field_info,
|
|
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,
|
|
410
|
+
if hasattr(field_info, "description"):
|
|
386
411
|
description_text = field_info.description
|
|
387
|
-
elif hasattr(field,
|
|
412
|
+
elif hasattr(field, "description"):
|
|
388
413
|
description_text = field.description
|
|
389
414
|
|
|
390
|
-
parameters.append(
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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(
|
|
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:
|
|
417
|
-
mime_type:
|
|
418
|
-
title:
|
|
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
|
|
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(
|
|
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
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TypeAlias
|
|
4
5
|
|
|
5
6
|
# Type alias for a logger factory function
|
|
6
|
-
|
|
7
|
+
LoggerFactory: TypeAlias = Callable[[str | None], logging.Logger]
|
|
7
8
|
|
|
8
9
|
# Internal variable to hold a custom logger factory
|
|
9
|
-
_custom_logger_factory:
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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=
|
|
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)
|