@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 +52 -0
- package/backend-sdk/palette_sdk/__init__.py +10 -1
- 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 +106 -0
- package/backend-sdk/palette_sdk/plugin_context.py +4 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +52 -0
- package/lib/bundler.js +2 -0
- package/lib/commands/publish.js +2 -0
- package/lib/dev-simulator.js +78 -1
- package/lib/manifest.js +101 -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
|
@@ -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.
|
|
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)),
|
|
@@ -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)
|
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
})
|
package/lib/dev-simulator.js
CHANGED
|
@@ -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: "
|
|
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