@palettelab/cli 0.3.54 → 0.3.56

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 CHANGED
@@ -396,6 +396,58 @@ The CLI validates manifest shape, SDK compatibility, frontend bundling, backend
396
396
  imports, backend route permission gates, declared permissions, migration safety,
397
397
  package dependency policy, and backend package size.
398
398
 
399
+ ## OS Notifications
400
+
401
+ Apps can push persistent notifications into the Palette OS notification center
402
+ (the bell) with `@palettelab/sdk@0.1.27+`:
403
+
404
+ ```ts
405
+ import { notifications } from "@palettelab/sdk"
406
+ // or: palette.notifications.push(...)
407
+
408
+ await notifications.push({
409
+ title: "Export complete",
410
+ body: "Your report is ready to download.",
411
+ severity: "success", // "info" | "success" | "warning" | "error"
412
+ targetApp: "reports-app", // app icon that owns the unread badge
413
+ route: "/exports/123", // opened inside your app on click
414
+ })
415
+ ```
416
+
417
+ The notification shows as a live toast plus a notification-center entry;
418
+ clicking it opens/focuses the target app at the resolved route. Use the SDK
419
+ helper rather than hand-building platform notification API calls.
420
+
421
+ Python plugin backends can push too, via `ctx.notifications` in the CLI-bundled
422
+ backend SDK:
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
+ "Approval needed",
432
+ body="A leave request needs approval.",
433
+ to=ctx.notifications.user(approver_user_id),
434
+ target_app="hierarchy-app",
435
+ )
436
+ ```
437
+
438
+ By default the backend helper notifies the user making the current request;
439
+ pass `to=ctx.notifications.user(...)`, `.email(...)`, `.member(...)`,
440
+ `.role(...)`, `.team(...)`, or `.mentions_from(...)` to notify other same-org
441
+ members. `target_app` controls the app icon badge and default click target. See
442
+ `docs/python-backend-sdk.md` ("OS Notifications From Python") for details.
443
+
444
+ During `pltt dev`, frontend pushes call the platform backend directly
445
+ (`NEXT_PUBLIC_API_URL`, default `http://localhost:8000`) in the user's session,
446
+ so they land in the real notification pool. Backend pushes run against the
447
+ local simulator, which logs them as `[palette-notification] {...}` and returns
448
+ a stub. Sandboxed iframe apps are not supported yet (no session cookie inside
449
+ the iframe).
450
+
399
451
  ## Commands
400
452
 
401
453
  ### `pltt init <name>`
@@ -6,6 +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 NotificationRecipient, NotificationsClient
9
10
  from palette_sdk.platform_services import (
10
11
  LocalRedisService,
11
12
  LocalVectorService,
@@ -44,6 +45,7 @@ from palette_sdk.services import (
44
45
  service,
45
46
  services,
46
47
  )
48
+ from palette_sdk.mcp import McpClient, mcp, mcp_prompt, mcp_resource, mcp_tool
47
49
  from palette_sdk.config import get_config, require_config
48
50
  from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
49
51
  from palette_sdk.testing import route_permission_issues
@@ -62,6 +64,8 @@ __all__ = [
62
64
  "AppServiceClient",
63
65
  "MissingAppServiceError",
64
66
  "OrganizationMembersClient",
67
+ "NotificationsClient",
68
+ "NotificationRecipient",
65
69
  "LocalRedisService",
66
70
  "LocalVectorService",
67
71
  "PlatformServiceUnavailable",
@@ -93,6 +97,11 @@ __all__ = [
93
97
  "BrokerCallError",
94
98
  "CrossAppGrantError",
95
99
  "MissingDependencyError",
100
+ "McpClient",
101
+ "mcp",
102
+ "mcp_prompt",
103
+ "mcp_resource",
104
+ "mcp_tool",
96
105
  "get_config",
97
106
  "require_config",
98
107
  "sign_webhook",
@@ -101,4 +110,4 @@ __all__ = [
101
110
  "LocalStorageService",
102
111
  ]
103
112
 
104
- __version__ = "0.1.8"
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))
@@ -0,0 +1,106 @@
1
+ """Backend OS notification helpers for plugin Python code.
2
+
3
+ Push a persistent notification into the OS notification center from a plugin
4
+ backend — the Python counterpart of the frontend SDK's `notifications.push()`:
5
+
6
+ from palette_sdk import PluginContext, get_plugin_context
7
+
8
+ @router.post("/exports", dependencies=[require_permission("resources:write")])
9
+ async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
10
+ ...
11
+ await ctx.notifications.push(
12
+ "Approval needed",
13
+ body="A leave request needs approval.",
14
+ to=ctx.notifications.user(approver_user_id),
15
+ target_app="hierarchy-app",
16
+ )
17
+
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
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Any
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
+
40
+ class NotificationsClient:
41
+ """Thin wrapper around the platform-injected notification service.
42
+
43
+ In production/hosted sandbox the platform injects the service into
44
+ `PluginContext`. In local unit tests, pass a fake service with the same
45
+ async `push` method.
46
+ """
47
+
48
+ def __init__(self, service: Any = None):
49
+ self._service = service
50
+
51
+ def _require_service(self) -> Any:
52
+ if self._service is None:
53
+ raise RuntimeError(
54
+ "Notification service is not available in this runtime. "
55
+ "Run inside Palette OS/hosted sandbox or inject a fake service in tests."
56
+ )
57
+ return self._service
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
+
84
+ async def push(
85
+ self,
86
+ title: str,
87
+ *,
88
+ body: str | None = None,
89
+ route: str | None = None,
90
+ severity: str | None = None,
91
+ data: dict[str, Any] | None = None,
92
+ user_id: str | None = None,
93
+ to: Any = None,
94
+ target_app: str | None = None,
95
+ ) -> dict[str, Any] | list[dict[str, Any]]:
96
+ """Push a notification; returns the serialized notification dict."""
97
+ return await self._require_service().push(
98
+ title=title,
99
+ body=body,
100
+ route=route,
101
+ severity=severity,
102
+ data=data,
103
+ user_id=user_id,
104
+ to=self._recipient_payload(to),
105
+ target_app=target_app,
106
+ )
@@ -14,6 +14,7 @@ from palette_sdk.data_rooms import DataRoomsClient
14
14
  from palette_sdk.connections import PluginConnectionsClient
15
15
  from palette_sdk.apps import AppInteropClient
16
16
  from palette_sdk.members import OrganizationMembersClient
17
+ from palette_sdk.notifications import NotificationsClient
17
18
  from palette_sdk.platform_services import UnavailablePlatformService
18
19
  from palette_sdk.events import EventPublisher
19
20
 
@@ -40,6 +41,7 @@ class PluginContext:
40
41
  permissions: List of permissions declared in the manifest
41
42
  storage: Storage service for file upload/download
42
43
  members: Organization member helpers for the current org
44
+ notifications: OS notification center push helpers
43
45
  """
44
46
  db: AsyncSession
45
47
  user_id: str
@@ -52,6 +54,7 @@ class PluginContext:
52
54
  connections: PluginConnectionsClient = field(default_factory=PluginConnectionsClient)
53
55
  apps: AppInteropClient = field(default_factory=AppInteropClient)
54
56
  members: OrganizationMembersClient = field(default_factory=OrganizationMembersClient)
57
+ notifications: NotificationsClient = field(default_factory=NotificationsClient)
55
58
  redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
56
59
  vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
57
60
  events: EventPublisher = field(default_factory=EventPublisher)
@@ -128,6 +131,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
128
131
  getattr(state, "org_members", None),
129
132
  getattr(state, "plugin_permissions", []),
130
133
  ),
134
+ notifications=NotificationsClient(getattr(state, "notifications", None)),
131
135
  redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
132
136
  vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
133
137
  events=EventPublisher(getattr(state, "plugin_events", None)),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.8"
3
+ version = "0.1.9"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -114,6 +114,7 @@ Available context values:
114
114
  | `ctx.data_rooms` | Backend Data Room client |
115
115
  | `ctx.connections` | Palette-managed third-party connection client |
116
116
  | `ctx.members` | Current organisation member client |
117
+ | `ctx.notifications` | Push OS notification-center notifications |
117
118
  | `ctx.apps` / `services(ctx)` | Governed app-to-app calls through declared `consumes` contracts |
118
119
  | `ctx.events` | Publish event topics declared in `provides.events` |
119
120
  | `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
@@ -141,6 +142,7 @@ These are the public Python helpers exported by `palette_sdk`.
141
142
  | `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
142
143
  | `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
143
144
  | `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
145
+ | `NotificationsClient`, `ctx.notifications` | Push notifications into the OS notification center |
144
146
  | `PluginConnectionsClient`, `ConnectionStatus`, `MissingConnectionError`, `ctx.connections` | Third-party connection status and token helpers |
145
147
  | `AppInteropClient`, `AppServiceClient`, `MissingAppServiceError`, `ctx.apps` | Call required apps/services without direct database access |
146
148
  | `service(...)`, `services(ctx)`, `ServicesClient`, `BrokerCallError` | Provide and consume OS-broker service methods/events |
@@ -592,6 +594,56 @@ In local unit tests outside the Palette runtime, inject a fake Data Room service
592
594
  if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
593
595
  real OS runtime, the platform injects the real service.
594
596
 
597
+ ## 8a. OS Notifications From Python
598
+
599
+ Backend code can push persistent notifications into the OS notification
600
+ center (the bell) — the Python counterpart of the frontend SDK's
601
+ `notifications.push()`:
602
+
603
+ ```python
604
+ from palette_sdk import PluginContext, get_plugin_context, require_permission
605
+
606
+ @router.post("/exports", dependencies=[require_permission("resources:write")])
607
+ async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
608
+ ...
609
+ await ctx.notifications.push(
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},
616
+ )
617
+ ```
618
+
619
+ The notification shows as a live toast plus a notification-center entry and
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
+
624
+ By default the notification targets the user making the current request. Use
625
+ recipient helpers to target same-org members:
626
+
627
+ ```python
628
+ await ctx.notifications.push(
629
+ "Approval needed",
630
+ body=f"{requester.name} requested a budget increase.",
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",
640
+ )
641
+ ```
642
+
643
+ During `pltt dev` notifications are not delivered to a real notification
644
+ center — the simulator logs them as `[palette-notification] {...}` and returns
645
+ a stub response, so your code paths still run.
646
+
595
647
  ## 9. Config And Secrets
596
648
 
597
649
  Use config for app install settings and secrets for sensitive values.
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)
@@ -410,6 +410,7 @@ async function run(argv, { cwd }) {
410
410
  log(`[pltt] ${backend.length} bytes`)
411
411
 
412
412
  const backendSha = sha256(backend)
413
+ const frontendSha = frontend ? sha256(frontend) : null
413
414
  const api = makeApi(env)
414
415
 
415
416
  log("[pltt] requesting signed URLs")
@@ -419,6 +420,7 @@ async function run(argv, { cwd }) {
419
420
  plugin_id: manifest.id,
420
421
  version: manifest.version,
421
422
  bundle_sha256: backendSha,
423
+ ...(frontendSha ? { frontend_sha256: frontendSha } : {}),
422
424
  publish_type: publishType,
423
425
  },
424
426
  })
@@ -178,6 +178,7 @@ import pathlib
178
178
  import re
179
179
  import sys
180
180
  import uuid
181
+ from datetime import datetime, timezone
181
182
  from types import SimpleNamespace
182
183
 
183
184
  from fastapi import FastAPI, HTTPException, Request, Response
@@ -228,6 +229,18 @@ if _service_enabled("storage"):
228
229
  organization_name="Palette Dev",
229
230
  )
230
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
+
231
244
  LOCAL_UPLOADS = {}
232
245
  CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
233
246
 
@@ -301,6 +314,51 @@ class LocalEventPublisher:
301
314
  async def publish(self, topic: str, payload=None):
302
315
  print("[palette-event]", topic, json.dumps(payload or {}, sort_keys=True))
303
316
 
317
+ class LocalNotificationsService:
318
+ """Local stand-in for the platform notification pool: logs and returns a stub."""
319
+
320
+ def __init__(self):
321
+ self._next_id = 1
322
+
323
+ def _resolve_action_route(self, route, plugin_id, target_app=None):
324
+ app_id = target_app or plugin_id
325
+ if not route:
326
+ return f"/apps/{app_id}" if app_id else None
327
+ if "://" in route:
328
+ raise ValueError("route must be an internal route starting with '/'")
329
+ if route.startswith("/apps/"):
330
+ return route
331
+ suffix = route if route.startswith("/") else "/" + route
332
+ return f"/apps/{app_id}{suffix}" if app_id else suffix
333
+
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):
335
+ if not title or not str(title).strip():
336
+ raise ValueError("notification title is required")
337
+ if severity is not None and severity not in ("info", "success", "warning", "error"):
338
+ raise ValueError("severity must be one of: info, success, warning, error")
339
+ plugin_id = MANIFEST.get("id", "")
340
+ badge_app = target_app_id or target_app
341
+ notification = {
342
+ "id": self._next_id,
343
+ "organization_id": 1,
344
+ "type": "app",
345
+ "title": str(title),
346
+ "body": body,
347
+ "data_json": json.dumps(data) if data is not None else None,
348
+ "source_app_id": plugin_id or None,
349
+ "target_app_id": badge_app or plugin_id or None,
350
+ "action_route": self._resolve_action_route(route, plugin_id, badge_app),
351
+ "severity": severity,
352
+ "is_read": False,
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"}),
355
+ }
356
+ self._next_id += 1
357
+ print("[palette-notification]", json.dumps(notification, sort_keys=True))
358
+ return notification
359
+
360
+ NOTIFICATIONS = LocalNotificationsService()
361
+
304
362
  spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
305
363
  module = importlib.util.module_from_spec(spec)
306
364
  assert spec and spec.loader
@@ -331,6 +389,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
331
389
  organization_id=1,
332
390
  )
333
391
  request.state.org_role = "owner"
392
+ request.state.organization_info = DEV_ORGANIZATION
334
393
  request.state.plugin_id = MANIFEST.get("id", "")
335
394
  request.state.plugin_permissions = MANIFEST.get("permissions", [])
336
395
  request.state.plugin_config = {
@@ -341,6 +400,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
341
400
  request.state.plugin_local_connections = DEV_CONNECTIONS
342
401
  request.state.plugin_apps = LocalAppInteropService()
343
402
  request.state.plugin_events = LocalEventPublisher()
403
+ request.state.notifications = NOTIFICATIONS
344
404
  request.state.storage = DEV_STORAGE
345
405
  if DEV_REDIS is not None:
346
406
  request.state.redis = DEV_REDIS
@@ -498,9 +558,26 @@ function connectionListResponse() {
498
558
  })
499
559
  }
500
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
+
501
572
  async function apiFetch(path, init) {
502
573
  const target = String(path || "")
503
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
+ }
504
581
  const connectionPrefix = "/api/v1/app-installs/" + encodeURIComponent(${JSON.stringify(manifest.id)}) + "/connections"
505
582
  if (target === connectionPrefix && method === "GET") {
506
583
  return connectionListResponse()
@@ -560,7 +637,7 @@ const basePlatform = {
560
637
  organizationId: 1,
561
638
  pluginId: ${JSON.stringify(manifest.id)},
562
639
  orgRole: "owner",
563
- orgs: [{ id: 1, name: "Local Dev Org", slug: "local-dev", theme_id: "default", logo_url: null }],
640
+ orgs: [{ id: 1, name: "Palette Dev", slug: "palette-dev", theme_id: "default", logo_url: null }],
564
641
  agents: [],
565
642
  permissions: ${JSON.stringify(manifest.permissions || [])},
566
643
  apiFetch,
package/lib/manifest.js CHANGED
@@ -32,6 +32,8 @@ const TOP_LEVEL_KEYS = new Set([
32
32
  "category",
33
33
  "tagline",
34
34
  "description",
35
+ "release_notes",
36
+ "changelog",
35
37
  "icon",
36
38
  "gradient",
37
39
  "sdk",
@@ -54,8 +56,13 @@ const TOP_LEVEL_KEYS = new Set([
54
56
  "provides",
55
57
  "requires",
56
58
  "consumes",
59
+ "mcp",
57
60
  ])
58
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
+
59
66
  function loadManifest(cwd) {
60
67
  const manifestPath = path.join(cwd, MANIFEST_FILE)
61
68
  if (!fs.existsSync(manifestPath)) {
@@ -505,6 +512,97 @@ function validateConsumes(value, errors) {
505
512
  }
506
513
  }
507
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
+
508
606
  function validateManifest(m) {
509
607
  const errors = []
510
608
  if (!isObject(m)) return ["manifest must be an object"]
@@ -526,6 +624,8 @@ function validateManifest(m) {
526
624
  requireString(m, "category", "manifest", errors)
527
625
  requireString(m, "tagline", "manifest", errors)
528
626
  requireString(m, "description", "manifest", errors)
627
+ requireString(m, "release_notes", "manifest", errors)
628
+ requireString(m, "changelog", "manifest", errors)
529
629
  requireString(m, "icon", "manifest", errors)
530
630
 
531
631
  if (m.gradient !== undefined) {
@@ -576,6 +676,7 @@ function validateManifest(m) {
576
676
  validateProvides(m.provides, errors)
577
677
  validateRequires(m.requires, errors)
578
678
  validateConsumes(m.consumes, errors)
679
+ validateMcp(m.mcp, errors)
579
680
 
580
681
  if (m.sdk) {
581
682
  if (!isObject(m.sdk)) errors.push("sdk must be an object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.54",
3
+ "version": "0.3.56",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.24"
7
+ "@palettelab/sdk": "^0.1.27"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.24",
5
+ "@palettelab/sdk": "^0.1.27",
6
6
  "react": "^19.0.0",
7
7
  "react-dom": "^19.0.0"
8
8
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.27",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.24", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.27", "react": "^19.0.0" }
6
6
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.24", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.27", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.27",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.27",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.24",
6
+ "@palettelab/sdk": "^0.1.27",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.24",
5
+ "@palettelab/sdk": "^0.1.27",
6
6
  "react": "^19.0.0"
7
7
  }
8
8
  }