@palettelab/cli 0.3.55 → 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
@@ -399,7 +399,7 @@ package dependency policy, and backend package size.
399
399
  ## OS Notifications
400
400
 
401
401
  Apps can push persistent notifications into the Palette OS notification center
402
- (the bell) with `@palettelab/sdk@0.1.25+`:
402
+ (the bell) with `@palettelab/sdk@0.1.27+`:
403
403
 
404
404
  ```ts
405
405
  import { notifications } from "@palettelab/sdk"
@@ -409,17 +409,17 @@ await notifications.push({
409
409
  title: "Export complete",
410
410
  body: "Your report is ready to download.",
411
411
  severity: "success", // "info" | "success" | "warning" | "error"
412
+ targetApp: "reports-app", // app icon that owns the unread badge
412
413
  route: "/exports/123", // opened inside your app on click
413
414
  })
414
415
  ```
415
416
 
416
417
  The notification shows as a live toast plus a notification-center entry;
417
- clicking it opens/focuses your app window at the resolved route. The stable
418
- contract is `POST /api/v1/notifications/` on the platform API (delivery streams
419
- over `GET /api/v1/notifications/stream`, SSE).
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
420
 
421
- Python plugin backends can push too, via `ctx.notifications` (palette-sdk
422
- `0.1.9+`):
421
+ Python plugin backends can push too, via `ctx.notifications` in the CLI-bundled
422
+ backend SDK:
423
423
 
424
424
  ```python
425
425
  from palette_sdk import PluginContext, get_plugin_context, require_permission
@@ -428,15 +428,17 @@ from palette_sdk import PluginContext, get_plugin_context, require_permission
428
428
  async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
429
429
  ...
430
430
  await ctx.notifications.push(
431
- "Export complete",
432
- body="Your report is ready to download.",
433
- severity="success",
434
- route="/exports/123",
431
+ "Approval needed",
432
+ body="A leave request needs approval.",
433
+ to=ctx.notifications.user(approver_user_id),
434
+ target_app="hierarchy-app",
435
435
  )
436
436
  ```
437
437
 
438
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
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
440
442
  `docs/python-backend-sdk.md` ("OS Notifications From Python") for details.
441
443
 
442
444
  During `pltt dev`, frontend pushes call the platform backend directly
@@ -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.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))
@@ -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
- "Export complete",
13
- body="Your report is ready to download.",
14
- severity="success",
15
- route="/exports/123",
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
- `route` resolves relative to the calling app (`/apps/{plugin_id}/exports/123`);
19
- pass an absolute `/apps/...` route to target another app's window. By default
20
- the notification goes to the user making the current request; pass `user_id`
21
- to notify a different member of the same organisation (the platform rejects
22
- targets outside the caller's org).
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
- ) -> dict[str, Any]:
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
- "Export complete",
611
- body="Your report is ready to download.",
612
- severity="success", # "info" | "success" | "warning" | "error"
613
- route="/exports/123", # opened inside your app on click
614
- data={"export_id": 123}, # arbitrary payload stored with it
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 your app window at the resolved route when clicked. `route`
620
- resolves relative to your app (`/apps/{plugin_id}/exports/123`); pass an
621
- absolute `/apps/...` route to target another app's window. Omit `route` to
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. Pass
625
- `user_id` to notify a different member of the same organisation (useful for
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
- route="/approvals",
633
- user_id=str(approver_id),
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)
@@ -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/{plugin_id}" if plugin_id else None
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/{plugin_id}{suffix}" if plugin_id else suffix
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
- "action_route": self._resolve_action_route(route, plugin_id),
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: "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 }],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.55",
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.25",
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.25",
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.25", "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.25", "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.25",
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.25",
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.25",
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.25",
5
+ "@palettelab/sdk": "^0.1.27",
6
6
  "react": "^19.0.0"
7
7
  }
8
8
  }