@palettelab/cli 0.3.55 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -27
- package/backend-sdk/palette_sdk/__init__.py +9 -2
- package/backend-sdk/palette_sdk/manifest.py +55 -0
- package/backend-sdk/palette_sdk/mcp.py +175 -0
- package/backend-sdk/palette_sdk/notifications.py +49 -10
- package/docs/python-backend-sdk.md +20 -14
- package/lib/bundler.js +2 -0
- package/lib/dev-simulator.js +40 -6
- package/lib/manifest.js +97 -0
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/templates/consumer-app/package.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/palette-app/package.json +1 -1
- package/template-fallback/templates/provider-app/package.json +1 -1
package/README.md
CHANGED
|
@@ -398,8 +398,39 @@ package dependency policy, and backend package size.
|
|
|
398
398
|
|
|
399
399
|
## OS Notifications
|
|
400
400
|
|
|
401
|
-
|
|
402
|
-
|
|
401
|
+
Backend apps created or served by the CLI should use the Python helper from the
|
|
402
|
+
CLI-bundled backend SDK. This is the helper to use for cross-user flows such as
|
|
403
|
+
leave approvals, review requests, task assignment, and same-organisation
|
|
404
|
+
recipient targeting:
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
from palette_sdk import PluginContext, get_plugin_context, require_permission
|
|
408
|
+
|
|
409
|
+
@router.post("/exports", dependencies=[require_permission("resources:write")])
|
|
410
|
+
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
411
|
+
...
|
|
412
|
+
await ctx.notifications.push(
|
|
413
|
+
"Approval needed",
|
|
414
|
+
body="A leave request needs approval.",
|
|
415
|
+
to=ctx.notifications.user(approver_user_id),
|
|
416
|
+
target_app="hierarchy-app",
|
|
417
|
+
route=f"/approvals/{request_id}",
|
|
418
|
+
severity="warning", # "info" | "success" | "warning" | "error"
|
|
419
|
+
data={"request_id": request_id},
|
|
420
|
+
)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
By default the backend helper notifies the user making the current request;
|
|
424
|
+
pass `to=ctx.notifications.user(...)`, `.email(...)`, `.member(...)`,
|
|
425
|
+
`.role(...)`, `.team(...)`, or `.mentions_from(...)` to notify other same-org
|
|
426
|
+
members. `target_app` controls the app icon badge and default click target.
|
|
427
|
+
`route` is optional and should only be set when the target app has a matching
|
|
428
|
+
deep-link page. See `docs/python-backend-sdk.md` ("OS Notifications From
|
|
429
|
+
Python") for details.
|
|
430
|
+
|
|
431
|
+
Frontend apps can also push notifications for the current signed-in user with
|
|
432
|
+
`@palettelab/sdk@0.1.27+`. This TypeScript helper is frontend-only and is not
|
|
433
|
+
the Python backend helper:
|
|
403
434
|
|
|
404
435
|
```ts
|
|
405
436
|
import { notifications } from "@palettelab/sdk"
|
|
@@ -409,35 +440,13 @@ await notifications.push({
|
|
|
409
440
|
title: "Export complete",
|
|
410
441
|
body: "Your report is ready to download.",
|
|
411
442
|
severity: "success", // "info" | "success" | "warning" | "error"
|
|
443
|
+
targetApp: "reports-app", // app icon that owns the unread badge
|
|
412
444
|
route: "/exports/123", // opened inside your app on click
|
|
413
445
|
})
|
|
414
446
|
```
|
|
415
447
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
contract is `POST /api/v1/notifications/` on the platform API (delivery streams
|
|
419
|
-
over `GET /api/v1/notifications/stream`, SSE).
|
|
420
|
-
|
|
421
|
-
Python plugin backends can push too, via `ctx.notifications` (palette-sdk
|
|
422
|
-
`0.1.9+`):
|
|
423
|
-
|
|
424
|
-
```python
|
|
425
|
-
from palette_sdk import PluginContext, get_plugin_context, require_permission
|
|
426
|
-
|
|
427
|
-
@router.post("/exports", dependencies=[require_permission("resources:write")])
|
|
428
|
-
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
429
|
-
...
|
|
430
|
-
await ctx.notifications.push(
|
|
431
|
-
"Export complete",
|
|
432
|
-
body="Your report is ready to download.",
|
|
433
|
-
severity="success",
|
|
434
|
-
route="/exports/123",
|
|
435
|
-
)
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
By default the backend helper notifies the user making the current request;
|
|
439
|
-
pass `user_id` to notify another member of the same organisation. See
|
|
440
|
-
`docs/python-backend-sdk.md` ("OS Notifications From Python") for details.
|
|
448
|
+
Both helpers create a live toast plus a notification-center entry. Use these
|
|
449
|
+
helpers rather than hand-building platform notification API calls.
|
|
441
450
|
|
|
442
451
|
During `pltt dev`, frontend pushes call the platform backend directly
|
|
443
452
|
(`NEXT_PUBLIC_API_URL`, default `http://localhost:8000`) in the user's session,
|
|
@@ -6,7 +6,7 @@ from palette_sdk.data_rooms import DataRoomsClient
|
|
|
6
6
|
from palette_sdk.connections import ConnectionStatus, MissingConnectionError, PluginConnectionsClient
|
|
7
7
|
from palette_sdk.apps import AppInteropClient, AppServiceClient, MissingAppServiceError
|
|
8
8
|
from palette_sdk.members import OrganizationMembersClient
|
|
9
|
-
from palette_sdk.notifications import NotificationsClient
|
|
9
|
+
from palette_sdk.notifications import NotificationRecipient, NotificationsClient
|
|
10
10
|
from palette_sdk.platform_services import (
|
|
11
11
|
LocalRedisService,
|
|
12
12
|
LocalVectorService,
|
|
@@ -45,6 +45,7 @@ from palette_sdk.services import (
|
|
|
45
45
|
service,
|
|
46
46
|
services,
|
|
47
47
|
)
|
|
48
|
+
from palette_sdk.mcp import McpClient, mcp, mcp_prompt, mcp_resource, mcp_tool
|
|
48
49
|
from palette_sdk.config import get_config, require_config
|
|
49
50
|
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
50
51
|
from palette_sdk.testing import route_permission_issues
|
|
@@ -64,6 +65,7 @@ __all__ = [
|
|
|
64
65
|
"MissingAppServiceError",
|
|
65
66
|
"OrganizationMembersClient",
|
|
66
67
|
"NotificationsClient",
|
|
68
|
+
"NotificationRecipient",
|
|
67
69
|
"LocalRedisService",
|
|
68
70
|
"LocalVectorService",
|
|
69
71
|
"PlatformServiceUnavailable",
|
|
@@ -95,6 +97,11 @@ __all__ = [
|
|
|
95
97
|
"BrokerCallError",
|
|
96
98
|
"CrossAppGrantError",
|
|
97
99
|
"MissingDependencyError",
|
|
100
|
+
"McpClient",
|
|
101
|
+
"mcp",
|
|
102
|
+
"mcp_prompt",
|
|
103
|
+
"mcp_resource",
|
|
104
|
+
"mcp_tool",
|
|
98
105
|
"get_config",
|
|
99
106
|
"require_config",
|
|
100
107
|
"sign_webhook",
|
|
@@ -103,4 +110,4 @@ __all__ = [
|
|
|
103
110
|
"LocalStorageService",
|
|
104
111
|
]
|
|
105
112
|
|
|
106
|
-
__version__ = "0.1.
|
|
113
|
+
__version__ = "0.1.9"
|
|
@@ -185,6 +185,60 @@ class PlatformServiceSpec(BaseModel):
|
|
|
185
185
|
billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
class McpToolSpec(BaseModel):
|
|
189
|
+
"""An MCP tool this app provides. Compiled into a broker service method
|
|
190
|
+
(namespace = provides.namespace) and mirrored into the org MCP catalog."""
|
|
191
|
+
|
|
192
|
+
name: str
|
|
193
|
+
label: str | None = None
|
|
194
|
+
description: str = ""
|
|
195
|
+
input_schema: dict[str, Any] | str | None = None
|
|
196
|
+
output_schema: dict[str, Any] | str | None = None
|
|
197
|
+
annotations: dict[str, Any] | None = None # readOnlyHint / destructiveHint ...
|
|
198
|
+
scope: str | None = None
|
|
199
|
+
expose_external: bool = False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class McpResourceSpec(BaseModel):
|
|
203
|
+
name: str
|
|
204
|
+
uri_template: str | None = None
|
|
205
|
+
description: str = ""
|
|
206
|
+
mime_type: str | None = None
|
|
207
|
+
expose_external: bool = False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class McpPromptSpec(BaseModel):
|
|
211
|
+
name: str
|
|
212
|
+
description: str = ""
|
|
213
|
+
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
|
214
|
+
expose_external: bool = False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class McpServerDependencySpec(BaseModel):
|
|
218
|
+
"""An external MCP connector (by org-configured slug) this app uses."""
|
|
219
|
+
|
|
220
|
+
slug: str
|
|
221
|
+
optional: bool = False
|
|
222
|
+
reason: str = ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class McpProvidesSpec(BaseModel):
|
|
226
|
+
tools: list[McpToolSpec] = Field(default_factory=list)
|
|
227
|
+
resources: list[McpResourceSpec] = Field(default_factory=list)
|
|
228
|
+
prompts: list[McpPromptSpec] = Field(default_factory=list)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class McpConsumesSpec(BaseModel):
|
|
232
|
+
servers: list[str | McpServerDependencySpec] = Field(default_factory=list)
|
|
233
|
+
tools: list[str] = Field(default_factory=list) # "mcp.{slug}/v1#{tool}" targets
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class McpSpec(BaseModel):
|
|
237
|
+
provides: McpProvidesSpec | None = None
|
|
238
|
+
consumes: McpConsumesSpec | None = None
|
|
239
|
+
exposes_to_external_clients: bool = False
|
|
240
|
+
|
|
241
|
+
|
|
188
242
|
class PluginManifest(BaseModel):
|
|
189
243
|
"""Validated plugin manifest from palette-plugin.json."""
|
|
190
244
|
|
|
@@ -214,6 +268,7 @@ class PluginManifest(BaseModel):
|
|
|
214
268
|
provides: ProvidesSpec | None = None
|
|
215
269
|
requires: RequiresSpec | None = None
|
|
216
270
|
consumes: ConsumesSpec | None = None
|
|
271
|
+
mcp: McpSpec | None = None
|
|
217
272
|
platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
218
273
|
rating: float = 0.0
|
|
219
274
|
reviews: int = 0
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""MCP helpers — provide tools to the OS, consume connector/app tools.
|
|
2
|
+
|
|
3
|
+
Provider example::
|
|
4
|
+
|
|
5
|
+
from palette_sdk import mcp_tool, PluginContext
|
|
6
|
+
|
|
7
|
+
@mcp_tool("invoice.summarize", description="Summarize an invoice")
|
|
8
|
+
async def summarize(ctx: PluginContext, payload: dict) -> dict:
|
|
9
|
+
return {"summary": ...}
|
|
10
|
+
|
|
11
|
+
An app's MCP tool *is* a broker service method under the app's namespace —
|
|
12
|
+
`@mcp_tool` registers through the same pending-handler list as `@service`, so
|
|
13
|
+
dispatch, cross-app grants, schema validation, and audit are identical.
|
|
14
|
+
Declare the tool in the manifest `mcp.provides.tools` block so it is indexed
|
|
15
|
+
and (optionally) exposed to external MCP clients.
|
|
16
|
+
|
|
17
|
+
Consumer example::
|
|
18
|
+
|
|
19
|
+
from palette_sdk import mcp
|
|
20
|
+
|
|
21
|
+
result = await mcp(ctx).call_tool("mcp.notion/v1#search_pages", {"query": "Q3"})
|
|
22
|
+
pages = await mcp(ctx).proxy("mcp.notion/v1").search_pages({"query": "Q3"})
|
|
23
|
+
|
|
24
|
+
Targets use the OS qualified-name grammar: `mcp.{connector_slug}/v1#{tool}`
|
|
25
|
+
for org-configured external servers, `{app_namespace}/v1#{tool}` for tools
|
|
26
|
+
provided by other installed apps. Both must be declared in the manifest
|
|
27
|
+
(`mcp.consumes.tools` or `consumes.services`).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Awaitable, Callable
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from palette_sdk.services import ServiceDecl, ServicesClient, _pending as _pending_services
|
|
37
|
+
|
|
38
|
+
ToolHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class McpResourceDecl:
|
|
43
|
+
name: str
|
|
44
|
+
handler: ToolHandler
|
|
45
|
+
uri_template: str | None = None
|
|
46
|
+
description: str | None = None
|
|
47
|
+
mime_type: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class McpPromptDecl:
|
|
52
|
+
name: str
|
|
53
|
+
handler: ToolHandler
|
|
54
|
+
description: str | None = None
|
|
55
|
+
arguments: list[dict[str, Any]] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_pending_resources: list[McpResourceDecl] = []
|
|
59
|
+
_pending_prompts: list[McpPromptDecl] = []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def mcp_tool(
|
|
63
|
+
name: str,
|
|
64
|
+
*,
|
|
65
|
+
description: str | None = None,
|
|
66
|
+
label: str | None = None,
|
|
67
|
+
input_schema: dict | None = None,
|
|
68
|
+
output_schema: dict | None = None,
|
|
69
|
+
scope: str | None = None,
|
|
70
|
+
) -> Callable[[ToolHandler], ToolHandler]:
|
|
71
|
+
"""Mark an async function as an MCP tool this app provides.
|
|
72
|
+
|
|
73
|
+
Registered as a broker service method (namespace/version come from the
|
|
74
|
+
manifest at load time, like `@service`).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def decorator(fn: ToolHandler) -> ToolHandler:
|
|
78
|
+
_pending_services.append(
|
|
79
|
+
ServiceDecl(
|
|
80
|
+
name=name,
|
|
81
|
+
handler=fn,
|
|
82
|
+
scope=scope,
|
|
83
|
+
label=label,
|
|
84
|
+
description=description,
|
|
85
|
+
input_schema=input_schema,
|
|
86
|
+
output_schema=output_schema,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return fn
|
|
90
|
+
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def mcp_resource(
|
|
95
|
+
name: str,
|
|
96
|
+
*,
|
|
97
|
+
uri_template: str | None = None,
|
|
98
|
+
description: str | None = None,
|
|
99
|
+
mime_type: str | None = None,
|
|
100
|
+
) -> Callable[[ToolHandler], ToolHandler]:
|
|
101
|
+
"""Mark an async function as an MCP resource provider (read-only)."""
|
|
102
|
+
|
|
103
|
+
def decorator(fn: ToolHandler) -> ToolHandler:
|
|
104
|
+
_pending_resources.append(
|
|
105
|
+
McpResourceDecl(
|
|
106
|
+
name=name,
|
|
107
|
+
handler=fn,
|
|
108
|
+
uri_template=uri_template,
|
|
109
|
+
description=description,
|
|
110
|
+
mime_type=mime_type,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return fn
|
|
114
|
+
|
|
115
|
+
return decorator
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def mcp_prompt(
|
|
119
|
+
name: str,
|
|
120
|
+
*,
|
|
121
|
+
description: str | None = None,
|
|
122
|
+
arguments: list[dict[str, Any]] | None = None,
|
|
123
|
+
) -> Callable[[ToolHandler], ToolHandler]:
|
|
124
|
+
"""Mark an async function as an MCP prompt provider."""
|
|
125
|
+
|
|
126
|
+
def decorator(fn: ToolHandler) -> ToolHandler:
|
|
127
|
+
_pending_prompts.append(
|
|
128
|
+
McpPromptDecl(
|
|
129
|
+
name=name,
|
|
130
|
+
handler=fn,
|
|
131
|
+
description=description,
|
|
132
|
+
arguments=list(arguments or []),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return fn
|
|
136
|
+
|
|
137
|
+
return decorator
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def drain_pending_mcp_resources() -> list[McpResourceDecl]:
|
|
141
|
+
"""Platform-only."""
|
|
142
|
+
out = list(_pending_resources)
|
|
143
|
+
_pending_resources.clear()
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def drain_pending_mcp_prompts() -> list[McpPromptDecl]:
|
|
148
|
+
"""Platform-only."""
|
|
149
|
+
out = list(_pending_prompts)
|
|
150
|
+
_pending_prompts.clear()
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ----- Consumer accessor -----------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class McpClient:
|
|
158
|
+
"""Returned by `mcp(ctx)`. Tool calls route through the OS broker, which
|
|
159
|
+
delegates `mcp.*` namespaces to the platform's MCP dispatch (connector
|
|
160
|
+
resolution, grants, schema validation, audit)."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, services_client: ServicesClient):
|
|
163
|
+
self._services = services_client
|
|
164
|
+
|
|
165
|
+
async def call_tool(self, target: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
166
|
+
return await self._services.call(target, arguments or {})
|
|
167
|
+
|
|
168
|
+
def proxy(self, namespace_version: str):
|
|
169
|
+
return self._services.proxy(namespace_version)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mcp(ctx: Any) -> McpClient:
|
|
173
|
+
"""Create an MCP client bound to a request context."""
|
|
174
|
+
adapter = getattr(ctx, "apps", None)
|
|
175
|
+
return McpClient(ServicesClient(adapter))
|
|
@@ -9,24 +9,34 @@ backend — the Python counterpart of the frontend SDK's `notifications.push()`:
|
|
|
9
9
|
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
10
10
|
...
|
|
11
11
|
await ctx.notifications.push(
|
|
12
|
-
"
|
|
13
|
-
body="
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
"Approval needed",
|
|
13
|
+
body="A leave request needs approval.",
|
|
14
|
+
to=ctx.notifications.user(approver_user_id),
|
|
15
|
+
target_app="hierarchy-app",
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
By default the notification goes to the user making the current request. Use
|
|
19
|
+
`to=ctx.notifications.user(...)`, `.email(...)`, `.member(...)`, `.role(...)`,
|
|
20
|
+
`.team(...)`, or `.mentions_from(...)` for explicit recipients. `target_app`
|
|
21
|
+
controls which app icon gets the unread badge and which app opens by default.
|
|
22
|
+
`route` is optional and only needed for a deep link inside that target app.
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
from dataclasses import dataclass
|
|
27
28
|
from typing import Any
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class NotificationRecipient:
|
|
33
|
+
kind: str
|
|
34
|
+
value: Any
|
|
35
|
+
|
|
36
|
+
def to_payload(self) -> dict[str, Any]:
|
|
37
|
+
return {"kind": self.kind, "value": self.value}
|
|
38
|
+
|
|
39
|
+
|
|
30
40
|
class NotificationsClient:
|
|
31
41
|
"""Thin wrapper around the platform-injected notification service.
|
|
32
42
|
|
|
@@ -46,6 +56,31 @@ class NotificationsClient:
|
|
|
46
56
|
)
|
|
47
57
|
return self._service
|
|
48
58
|
|
|
59
|
+
def user(self, user_id: str) -> NotificationRecipient:
|
|
60
|
+
return NotificationRecipient("user", str(user_id))
|
|
61
|
+
|
|
62
|
+
def email(self, email: str) -> NotificationRecipient:
|
|
63
|
+
return NotificationRecipient("email", email)
|
|
64
|
+
|
|
65
|
+
def member(self, handle_or_email: str) -> NotificationRecipient:
|
|
66
|
+
return NotificationRecipient("member", handle_or_email)
|
|
67
|
+
|
|
68
|
+
def role(self, role: str) -> NotificationRecipient:
|
|
69
|
+
return NotificationRecipient("role", role)
|
|
70
|
+
|
|
71
|
+
def team(self, team: str | int) -> NotificationRecipient:
|
|
72
|
+
return NotificationRecipient("team", team)
|
|
73
|
+
|
|
74
|
+
def mentions_from(self, message: str) -> NotificationRecipient:
|
|
75
|
+
return NotificationRecipient("mentions", message)
|
|
76
|
+
|
|
77
|
+
def _recipient_payload(self, to: Any) -> Any:
|
|
78
|
+
if isinstance(to, NotificationRecipient):
|
|
79
|
+
return to.to_payload()
|
|
80
|
+
if isinstance(to, (list, tuple, set)):
|
|
81
|
+
return [self._recipient_payload(item) for item in to]
|
|
82
|
+
return to
|
|
83
|
+
|
|
49
84
|
async def push(
|
|
50
85
|
self,
|
|
51
86
|
title: str,
|
|
@@ -55,7 +90,9 @@ class NotificationsClient:
|
|
|
55
90
|
severity: str | None = None,
|
|
56
91
|
data: dict[str, Any] | None = None,
|
|
57
92
|
user_id: str | None = None,
|
|
58
|
-
|
|
93
|
+
to: Any = None,
|
|
94
|
+
target_app: str | None = None,
|
|
95
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
59
96
|
"""Push a notification; returns the serialized notification dict."""
|
|
60
97
|
return await self._require_service().push(
|
|
61
98
|
title=title,
|
|
@@ -64,4 +101,6 @@ class NotificationsClient:
|
|
|
64
101
|
severity=severity,
|
|
65
102
|
data=data,
|
|
66
103
|
user_id=user_id,
|
|
104
|
+
to=self._recipient_payload(to),
|
|
105
|
+
target_app=target_app,
|
|
67
106
|
)
|
|
@@ -607,30 +607,36 @@ from palette_sdk import PluginContext, get_plugin_context, require_permission
|
|
|
607
607
|
async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
608
608
|
...
|
|
609
609
|
await ctx.notifications.push(
|
|
610
|
-
"
|
|
611
|
-
body="
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
610
|
+
"Approval needed",
|
|
611
|
+
body="A leave request needs approval.",
|
|
612
|
+
to=ctx.notifications.user(approver_user_id),
|
|
613
|
+
target_app="hierarchy-app",
|
|
614
|
+
severity="warning", # "info" | "success" | "warning" | "error"
|
|
615
|
+
data={"request_id": request_id},
|
|
615
616
|
)
|
|
616
617
|
```
|
|
617
618
|
|
|
618
619
|
The notification shows as a live toast plus a notification-center entry and
|
|
619
|
-
opens/focuses
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
open your app's main window.
|
|
620
|
+
opens/focuses the target app when clicked. `target_app` controls the app icon
|
|
621
|
+
badge and default click target. `route` is optional; pass it only for a
|
|
622
|
+
deep-link inside the target app.
|
|
623
623
|
|
|
624
|
-
By default the notification targets the user making the current request.
|
|
625
|
-
|
|
626
|
-
approval flows); targets outside the caller's organisation are rejected:
|
|
624
|
+
By default the notification targets the user making the current request. Use
|
|
625
|
+
recipient helpers to target same-org members:
|
|
627
626
|
|
|
628
627
|
```python
|
|
629
628
|
await ctx.notifications.push(
|
|
630
629
|
"Approval needed",
|
|
631
630
|
body=f"{requester.name} requested a budget increase.",
|
|
632
|
-
|
|
633
|
-
|
|
631
|
+
to=ctx.notifications.email("approver@example.com"),
|
|
632
|
+
target_app="approvals-app",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
await ctx.notifications.push(
|
|
636
|
+
"Team update",
|
|
637
|
+
body="Finance review is ready.",
|
|
638
|
+
to=ctx.notifications.team("finance"),
|
|
639
|
+
target_app="finance-app",
|
|
634
640
|
)
|
|
635
641
|
```
|
|
636
642
|
|
package/lib/bundler.js
CHANGED
|
@@ -596,6 +596,8 @@ async function bundleBackend(pluginDir, options = {}) {
|
|
|
596
596
|
|
|
597
597
|
const backendDir = path.join(pluginDir, "backend")
|
|
598
598
|
if (fs.existsSync(backendDir)) copy(backendDir, path.join(stage, "backend"))
|
|
599
|
+
const schemasDir = path.join(pluginDir, "schemas")
|
|
600
|
+
if (fs.existsSync(schemasDir)) copy(schemasDir, path.join(stage, "schemas"))
|
|
599
601
|
for (const metadataFile of ["package.json", "pyproject.toml", "palette-plugin.json"]) {
|
|
600
602
|
if (metadataFile === "palette-plugin.json" && options.manifest) continue
|
|
601
603
|
const src = path.join(pluginDir, metadataFile)
|
package/lib/dev-simulator.js
CHANGED
|
@@ -229,6 +229,18 @@ if _service_enabled("storage"):
|
|
|
229
229
|
organization_name="Palette Dev",
|
|
230
230
|
)
|
|
231
231
|
|
|
232
|
+
DEV_ORGANIZATION = {
|
|
233
|
+
"id": 1,
|
|
234
|
+
"name": "Palette Dev",
|
|
235
|
+
"slug": "palette-dev",
|
|
236
|
+
"description": "Local development organisation",
|
|
237
|
+
"company_type": "IT & Engineering",
|
|
238
|
+
"logo_url": None,
|
|
239
|
+
"theme_id": "mac",
|
|
240
|
+
"created_at": "2026-01-01T00:00:00+00:00",
|
|
241
|
+
"updated_at": "2026-01-01T00:00:00+00:00",
|
|
242
|
+
}
|
|
243
|
+
|
|
232
244
|
LOCAL_UPLOADS = {}
|
|
233
245
|
CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
|
|
234
246
|
|
|
@@ -308,22 +320,24 @@ class LocalNotificationsService:
|
|
|
308
320
|
def __init__(self):
|
|
309
321
|
self._next_id = 1
|
|
310
322
|
|
|
311
|
-
def _resolve_action_route(self, route, plugin_id):
|
|
323
|
+
def _resolve_action_route(self, route, plugin_id, target_app=None):
|
|
324
|
+
app_id = target_app or plugin_id
|
|
312
325
|
if not route:
|
|
313
|
-
return f"/apps/{
|
|
326
|
+
return f"/apps/{app_id}" if app_id else None
|
|
314
327
|
if "://" in route:
|
|
315
328
|
raise ValueError("route must be an internal route starting with '/'")
|
|
316
329
|
if route.startswith("/apps/"):
|
|
317
330
|
return route
|
|
318
331
|
suffix = route if route.startswith("/") else "/" + route
|
|
319
|
-
return f"/apps/{
|
|
332
|
+
return f"/apps/{app_id}{suffix}" if app_id else suffix
|
|
320
333
|
|
|
321
|
-
async def push(self, *, title, body=None, route=None, severity=None, data=None, user_id=None):
|
|
334
|
+
async def push(self, *, title, body=None, route=None, severity=None, data=None, user_id=None, to=None, target_app=None, target_app_id=None):
|
|
322
335
|
if not title or not str(title).strip():
|
|
323
336
|
raise ValueError("notification title is required")
|
|
324
337
|
if severity is not None and severity not in ("info", "success", "warning", "error"):
|
|
325
338
|
raise ValueError("severity must be one of: info, success, warning, error")
|
|
326
339
|
plugin_id = MANIFEST.get("id", "")
|
|
340
|
+
badge_app = target_app_id or target_app
|
|
327
341
|
notification = {
|
|
328
342
|
"id": self._next_id,
|
|
329
343
|
"organization_id": 1,
|
|
@@ -332,10 +346,12 @@ class LocalNotificationsService:
|
|
|
332
346
|
"body": body,
|
|
333
347
|
"data_json": json.dumps(data) if data is not None else None,
|
|
334
348
|
"source_app_id": plugin_id or None,
|
|
335
|
-
"
|
|
349
|
+
"target_app_id": badge_app or plugin_id or None,
|
|
350
|
+
"action_route": self._resolve_action_route(route, plugin_id, badge_app),
|
|
336
351
|
"severity": severity,
|
|
337
352
|
"is_read": False,
|
|
338
353
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
354
|
+
"local_recipient": to or ({"kind": "user", "value": str(user_id)} if user_id is not None else {"kind": "current_user", "value": "local"}),
|
|
339
355
|
}
|
|
340
356
|
self._next_id += 1
|
|
341
357
|
print("[palette-notification]", json.dumps(notification, sort_keys=True))
|
|
@@ -373,6 +389,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
373
389
|
organization_id=1,
|
|
374
390
|
)
|
|
375
391
|
request.state.org_role = "owner"
|
|
392
|
+
request.state.organization_info = DEV_ORGANIZATION
|
|
376
393
|
request.state.plugin_id = MANIFEST.get("id", "")
|
|
377
394
|
request.state.plugin_permissions = MANIFEST.get("permissions", [])
|
|
378
395
|
request.state.plugin_config = {
|
|
@@ -541,9 +558,26 @@ function connectionListResponse() {
|
|
|
541
558
|
})
|
|
542
559
|
}
|
|
543
560
|
|
|
561
|
+
const devOrganization = {
|
|
562
|
+
id: 1,
|
|
563
|
+
name: "Palette Dev",
|
|
564
|
+
slug: "palette-dev",
|
|
565
|
+
description: "Local development organisation",
|
|
566
|
+
company_type: "IT & Engineering",
|
|
567
|
+
logo_url: null,
|
|
568
|
+
created_at: "2026-01-01T00:00:00+00:00",
|
|
569
|
+
updated_at: "2026-01-01T00:00:00+00:00",
|
|
570
|
+
}
|
|
571
|
+
|
|
544
572
|
async function apiFetch(path, init) {
|
|
545
573
|
const target = String(path || "")
|
|
546
574
|
const method = String(init?.method || "GET").toUpperCase()
|
|
575
|
+
if (target === "/api/v1/org" && method === "GET") {
|
|
576
|
+
return new Response(JSON.stringify(devOrganization), {
|
|
577
|
+
status: 200,
|
|
578
|
+
headers: { "Content-Type": "application/json" },
|
|
579
|
+
})
|
|
580
|
+
}
|
|
547
581
|
const connectionPrefix = "/api/v1/app-installs/" + encodeURIComponent(${JSON.stringify(manifest.id)}) + "/connections"
|
|
548
582
|
if (target === connectionPrefix && method === "GET") {
|
|
549
583
|
return connectionListResponse()
|
|
@@ -603,7 +637,7 @@ const basePlatform = {
|
|
|
603
637
|
organizationId: 1,
|
|
604
638
|
pluginId: ${JSON.stringify(manifest.id)},
|
|
605
639
|
orgRole: "owner",
|
|
606
|
-
orgs: [{ id: 1, name: "
|
|
640
|
+
orgs: [{ id: 1, name: "Palette Dev", slug: "palette-dev", theme_id: "default", logo_url: null }],
|
|
607
641
|
agents: [],
|
|
608
642
|
permissions: ${JSON.stringify(manifest.permissions || [])},
|
|
609
643
|
apiFetch,
|
package/lib/manifest.js
CHANGED
|
@@ -56,8 +56,13 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
56
56
|
"provides",
|
|
57
57
|
"requires",
|
|
58
58
|
"consumes",
|
|
59
|
+
"mcp",
|
|
59
60
|
])
|
|
60
61
|
|
|
62
|
+
const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/
|
|
63
|
+
const MCP_CONNECTOR_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,78}$/
|
|
64
|
+
const MCP_TOOL_TARGET_RE = /^mcp\.[a-z0-9][a-z0-9_-]*\/[A-Za-z0-9_.-]+#[A-Za-z0-9_.-]+$/
|
|
65
|
+
|
|
61
66
|
function loadManifest(cwd) {
|
|
62
67
|
const manifestPath = path.join(cwd, MANIFEST_FILE)
|
|
63
68
|
if (!fs.existsSync(manifestPath)) {
|
|
@@ -507,6 +512,97 @@ function validateConsumes(value, errors) {
|
|
|
507
512
|
}
|
|
508
513
|
}
|
|
509
514
|
|
|
515
|
+
function validateMcp(value, errors) {
|
|
516
|
+
if (value === undefined) return
|
|
517
|
+
if (!isObject(value)) {
|
|
518
|
+
errors.push("mcp must be an object")
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
unknownKeys(value, new Set(["provides", "consumes", "exposes_to_external_clients"]), "mcp", errors)
|
|
522
|
+
requireBoolean(value, "exposes_to_external_clients", "mcp", errors)
|
|
523
|
+
|
|
524
|
+
if (value.provides !== undefined) {
|
|
525
|
+
if (!isObject(value.provides)) {
|
|
526
|
+
errors.push("mcp.provides must be an object")
|
|
527
|
+
} else {
|
|
528
|
+
unknownKeys(value.provides, new Set(["tools", "resources", "prompts"]), "mcp.provides", errors)
|
|
529
|
+
const allowedByBucket = {
|
|
530
|
+
tools: new Set(["name", "label", "description", "input_schema", "output_schema", "annotations", "scope", "expose_external"]),
|
|
531
|
+
resources: new Set(["name", "uri_template", "description", "mime_type", "expose_external"]),
|
|
532
|
+
prompts: new Set(["name", "description", "arguments", "expose_external"]),
|
|
533
|
+
}
|
|
534
|
+
for (const bucket of ["tools", "resources", "prompts"]) {
|
|
535
|
+
const entries = value.provides[bucket]
|
|
536
|
+
if (entries === undefined) continue
|
|
537
|
+
if (!Array.isArray(entries)) {
|
|
538
|
+
errors.push(`mcp.provides.${bucket} must be an array`)
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
const seen = new Set()
|
|
542
|
+
entries.forEach((entry, i) => {
|
|
543
|
+
const label = `mcp.provides.${bucket}[${i}]`
|
|
544
|
+
if (!isObject(entry)) {
|
|
545
|
+
errors.push(`${label} must be an object`)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
unknownKeys(entry, allowedByBucket[bucket], label, errors)
|
|
549
|
+
if (typeof entry.name !== "string" || !MCP_NAME_RE.test(entry.name)) {
|
|
550
|
+
errors.push(`${label}.name must be a valid MCP name`)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
if (seen.has(entry.name)) errors.push(`duplicate mcp.provides.${bucket} name: ${entry.name}`)
|
|
554
|
+
seen.add(entry.name)
|
|
555
|
+
requireBoolean(entry, "expose_external", label, errors)
|
|
556
|
+
requireString(entry, "description", label, errors)
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (value.consumes !== undefined) {
|
|
563
|
+
if (!isObject(value.consumes)) {
|
|
564
|
+
errors.push("mcp.consumes must be an object")
|
|
565
|
+
} else {
|
|
566
|
+
unknownKeys(value.consumes, new Set(["servers", "tools"]), "mcp.consumes", errors)
|
|
567
|
+
if (value.consumes.servers !== undefined) {
|
|
568
|
+
if (!Array.isArray(value.consumes.servers)) {
|
|
569
|
+
errors.push("mcp.consumes.servers must be an array")
|
|
570
|
+
} else {
|
|
571
|
+
value.consumes.servers.forEach((entry, i) => {
|
|
572
|
+
const label = `mcp.consumes.servers[${i}]`
|
|
573
|
+
let slug
|
|
574
|
+
if (typeof entry === "string") {
|
|
575
|
+
slug = entry
|
|
576
|
+
} else if (isObject(entry)) {
|
|
577
|
+
unknownKeys(entry, new Set(["slug", "optional", "reason"]), label, errors)
|
|
578
|
+
slug = entry.slug
|
|
579
|
+
requireBoolean(entry, "optional", label, errors)
|
|
580
|
+
requireString(entry, "reason", label, errors)
|
|
581
|
+
} else {
|
|
582
|
+
errors.push(`${label} must be a slug string or object`)
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
if (typeof slug !== "string" || !MCP_CONNECTOR_SLUG_RE.test(slug)) {
|
|
586
|
+
errors.push(`${label}.slug must be a lowercase connector slug`)
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (value.consumes.tools !== undefined) {
|
|
592
|
+
if (!Array.isArray(value.consumes.tools)) {
|
|
593
|
+
errors.push("mcp.consumes.tools must be an array")
|
|
594
|
+
} else {
|
|
595
|
+
value.consumes.tools.forEach((entry, i) => {
|
|
596
|
+
if (typeof entry !== "string" || !MCP_TOOL_TARGET_RE.test(entry)) {
|
|
597
|
+
errors.push(`mcp.consumes.tools[${i}] must look like mcp.{slug}/v1#tool`)
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
510
606
|
function validateManifest(m) {
|
|
511
607
|
const errors = []
|
|
512
608
|
if (!isObject(m)) return ["manifest must be an object"]
|
|
@@ -580,6 +676,7 @@ function validateManifest(m) {
|
|
|
580
676
|
validateProvides(m.provides, errors)
|
|
581
677
|
validateRequires(m.requires, errors)
|
|
582
678
|
validateConsumes(m.consumes, errors)
|
|
679
|
+
validateMcp(m.mcp, errors)
|
|
583
680
|
|
|
584
681
|
if (m.sdk) {
|
|
585
682
|
if (!isObject(m.sdk)) errors.push("sdk must be an object")
|
package/package.json
CHANGED