@cosmoops/connect-sdk 0.15.0 → 0.16.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +2 -1
  3. package/dist/cjs/client/components/ai-screens.js +73 -0
  4. package/dist/cjs/client/components/ai-widgets.js +37 -0
  5. package/dist/cjs/client/components/ask-ai.js +31 -5
  6. package/dist/cjs/client/index.js +5 -2
  7. package/dist/cjs/index.js +6 -1
  8. package/dist/cjs/mcp/index.js +90 -0
  9. package/dist/cjs/mcp/tools.js +369 -0
  10. package/dist/cjs/server/client.js +1 -1
  11. package/dist/cjs/server/handler.js +7 -0
  12. package/dist/cjs/server/resources/ai.js +8 -1
  13. package/dist/cjs/store-schema.js +66 -0
  14. package/dist/esm/client/components/ai-screens.js +69 -0
  15. package/dist/esm/client/components/ai-widgets.js +37 -1
  16. package/dist/esm/client/components/ask-ai.js +32 -6
  17. package/dist/esm/client/index.js +1 -0
  18. package/dist/esm/index.js +3 -0
  19. package/dist/esm/mcp/index.js +85 -0
  20. package/dist/esm/mcp/tools.js +366 -0
  21. package/dist/esm/server/client.js +1 -1
  22. package/dist/esm/server/handler.js +7 -0
  23. package/dist/esm/server/resources/ai.js +8 -1
  24. package/dist/esm/store-schema.js +62 -0
  25. package/dist/types/client/components/ai-screens.d.ts +25 -0
  26. package/dist/types/client/components/ai-widgets.d.ts +34 -1
  27. package/dist/types/client/components/ask-ai.d.ts +47 -1
  28. package/dist/types/client/index.d.ts +2 -0
  29. package/dist/types/index.d.ts +4 -0
  30. package/dist/types/mcp/index.d.ts +85 -0
  31. package/dist/types/mcp/tools.d.ts +107 -0
  32. package/dist/types/server/client.d.ts +1 -1
  33. package/dist/types/server/resources/ai.d.ts +11 -1
  34. package/dist/types/store-schema.d.ts +52 -0
  35. package/dist/types/types.d.ts +50 -1
  36. package/package.json +10 -1
@@ -5,7 +5,9 @@ import { Button, Input, Label, TextField } from "@heroui/react";
5
5
  import { Brain, ChevronDown, Send, Sparkles, Square, X } from "lucide-react";
6
6
  import { Streamdown } from "streamdown";
7
7
  import { useConnect } from "../provider.js";
8
- import { AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
8
+ import { useActivePath } from "../use-active-path.js";
9
+ import { matchConnectScreen } from "./ai-screens.js";
10
+ import { AiActions, AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
9
11
  /**
10
12
  * The panel's open width. The *inner* pane always carries it, so the content
11
13
  * keeps its layout while the outer shell's width animates to zero around it.
@@ -63,7 +65,7 @@ function ThinkingDisclosure({ text, isStreaming }) {
63
65
  */
64
66
  const isArray = (value) => Array.isArray(value) && value.length > 0;
65
67
  const field = (input, key) => typeof input === "object" && input !== null ? input[key] : undefined;
66
- function renderTool(name, input, renderMarkdown) {
68
+ function renderTool(name, input, renderMarkdown, screens, renderLink) {
67
69
  switch (name) {
68
70
  case "showTable":
69
71
  return isArray(field(input, "columns")) && Array.isArray(field(input, "rows")) ? (_jsx(AiTable, { ...input })) : null;
@@ -79,6 +81,12 @@ function renderTool(name, input, renderMarkdown) {
79
81
  return isArray(field(input, "steps")) ? (_jsx(AiSteps, { ...input, renderDetail: renderMarkdown })) : null;
80
82
  case "showSources":
81
83
  return isArray(field(input, "sources")) ? _jsx(AiSources, { ...input }) : null;
84
+ case "showActions":
85
+ // Every href is checked against the declared screens before anything is
86
+ // drawn - see `AiActions`. With none declared, nothing resolves and the
87
+ // tool renders nothing, which is the correct outcome rather than a
88
+ // collection of dead links.
89
+ return isArray(field(input, "actions")) ? (_jsx(AiActions, { ...input, screens: screens, renderLink: renderLink })) : null;
82
90
  default:
83
91
  return null;
84
92
  }
@@ -220,7 +228,7 @@ function appendText(blocks, text) {
220
228
  * Neither is something the browser can discover, so both surface as a message
221
229
  * in the panel rather than as a hidden button.
222
230
  */
223
- export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placeholder = "Ask a question...", hint = "Ask a question about your workspace - answers are grounded in what your team has indexed.", defaultOpen = false, markdownPlugins, }) {
231
+ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placeholder = "Ask a question...", hint = "Ask a question about your workspace - answers are grounded in what your team has indexed.", defaultOpen = false, markdownPlugins, screens = [], activePath, renderLink, }) {
224
232
  const { session, isLoading, streamResource } = useConnect();
225
233
  const [isOpen, setIsOpen] = useState(defaultOpen);
226
234
  const [turns, setTurns] = useState([]);
@@ -239,6 +247,16 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
239
247
  // A conversation left mid-stream should stop costing tokens the moment the
240
248
  // panel goes away.
241
249
  useEffect(() => () => abortRef.current?.abort(), []);
250
+ /**
251
+ * The declared screen the user is on, if any.
252
+ *
253
+ * Drives the opening chips here and travels with each message so the answer
254
+ * is written from where they are standing. `null` whenever nothing matches -
255
+ * an application need only describe the screens it wants the assistant to
256
+ * know about.
257
+ */
258
+ const resolvedPath = useActivePath(activePath);
259
+ const currentScreen = useMemo(() => (resolvedPath ? matchConnectScreen(screens, resolvedPath) : null), [screens, resolvedPath]);
242
260
  const renderMarkdown = useCallback((text, isStreaming = false) => (_jsx(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
243
261
  // Retrieved documents cite real URLs; an interstitial on every one of
244
262
  // them turns a citation into a chore. The renderer still sanitises the
@@ -283,7 +301,15 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
283
301
  try {
284
302
  // Relative to the provider's own `basePath` - `streamResource` prefixes
285
303
  // it, the same way every other bridge call is addressed.
286
- const response = await streamResource("/ai/chat", { messages: history }, { signal: controller.signal });
304
+ const response = await streamResource("/ai/chat", {
305
+ messages: history,
306
+ // What the assistant is told about where the user is, and what your
307
+ // application's screens are. Both are prose: Connect uses them to
308
+ // write a better answer and to validate action links, never to
309
+ // decide what anyone may do. See `ConnectScreen`.
310
+ ...(resolvedPath ? { screenContext: { pathname: resolvedPath } } : {}),
311
+ ...(screens.length > 0 ? { screens } : {}),
312
+ }, { signal: controller.signal });
287
313
  // The assistant's turn is appended only once the stream is live, so a
288
314
  // failed request leaves an error under the question rather than an
289
315
  // empty bubble that never fills.
@@ -330,7 +356,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
330
356
  // The user's own turn is shown as they typed it - running a
331
357
  // question that happens to start with `#` through the markdown
332
358
  // renderer would turn it into a heading.
333
- _jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
359
+ _jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown, screens, renderLink) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
334
360
  }), [turns, isAnswering, lastTurn?.id, assistantName, renderMarkdown]);
335
361
  // Signed out there is no workspace to ground an answer in, and the bridge
336
362
  // would answer 401 - so there is nothing to offer, not even a button.
@@ -344,7 +370,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
344
370
  setInput("");
345
371
  void send(text);
346
372
  };
347
- return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: _jsx(Sparkles, { className: "size-5" }) })), _jsx("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), _jsx("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && _jsx("p", { className: "text-sm text-muted", children: hint }), rendered, isBusy && lastTurn?.role === "user" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
373
+ return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: _jsx(Sparkles, { className: "size-5" }) })), _jsx("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), _jsx("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => (_jsx(Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
348
374
  }
349
375
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
350
376
  const LINK_SAFETY = { enabled: false };
@@ -56,6 +56,7 @@ export { useActivePath } from "./use-active-path.js";
56
56
  // same longest-prefix rule the sidebar highlights with, so a breadcrumb or a
57
57
  // page title derived from it cannot disagree with the rail.
58
58
  export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, filterNavForAudience, } from "./components/nav.js";
59
+ export { matchConnectScreen, isLinkableConnectScreen, } from "./components/ai-screens.js";
59
60
  export { ConnectAppsGrid } from "./components/apps-grid.js";
60
61
  // `AppShell askAi` mounts this; exported for an app that wants the panel
61
62
  // without the frame, or wants it somewhere other than the shell's flex row
package/dist/esm/index.js CHANGED
@@ -10,7 +10,10 @@
10
10
  * Holds the API key. Never import this from browser code.
11
11
  * - `@cosmoops/connect-sdk/client` - React components and hooks. Talks to your
12
12
  * own origin only.
13
+ * - `@cosmoops/connect-sdk/mcp` - your store's declaration, derived into MCP
14
+ * tools. Holds the API key; never import this from browser code.
13
15
  */
14
16
  export { ConnectError, isConnectError } from "./errors.js";
15
17
  export { blockingBlockers, normalizePlanFeatures } from "./helpers.js";
16
18
  export { arrayRemove, arrayUnion, deleteField, documentExists, increment, serverTimestamp, storeIndexFor, storeIndexSignature, unchangedSince, } from "./store.js";
19
+ export { describeQueryRejection, storeIndexCovers } from "./store-schema.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `@cosmoops/connect-sdk/mcp` - your application's store, as an MCP server.
3
+ *
4
+ * Server-only: every call goes out through `ConnectClient`, which holds the API
5
+ * key. The `browser` export condition resolves this entry to a stub, so an
6
+ * import from client code fails the build rather than shipping the key.
7
+ *
8
+ * ## Zero dependencies, on purpose
9
+ *
10
+ * This entry does not import `@modelcontextprotocol/sdk`, and the package takes
11
+ * no dependency on it. `listTools()` and `callTool()` answer in the shapes the
12
+ * MCP protocol defines, so wiring them to the official server is four lines and
13
+ * a transport you choose:
14
+ *
15
+ * ```ts
16
+ * const toolset = createStoreToolset({
17
+ * collections: STORE_SCHEMA,
18
+ * data: connect.data,
19
+ * workspaceId: session.workspaceId,
20
+ * asUser: session.sessionToken,
21
+ * })
22
+ *
23
+ * server.setRequestHandler(ListToolsRequestSchema, () => toolset.listTools())
24
+ * server.setRequestHandler(CallToolRequestSchema, (request) =>
25
+ * toolset.callTool(request.params.name, request.params.arguments ?? {}),
26
+ * )
27
+ * ```
28
+ *
29
+ * The alternative - depending on the MCP SDK so this file could return an
30
+ * `McpServer` - would put a version of it in every install of this package,
31
+ * including the ones that never touch MCP, and pin partners to whichever
32
+ * version shipped here. The protocol shapes are stable and small; the coupling
33
+ * would not be.
34
+ *
35
+ * ## One toolset per session
36
+ *
37
+ * `asUser` is an end user's Connect session token, and Connect checks each
38
+ * collection's declared permission against that member's live roles. A toolset
39
+ * is therefore scoped to one signed-in user - build it per request from
40
+ * `getConnectSession()`, or pass a function for `asUser` if the surrounding
41
+ * server outlives a session.
42
+ */
43
+ export { deriveStoreTools } from "./tools.js";
44
+ import { deriveStoreTools } from "./tools.js";
45
+ /**
46
+ * A refusal is a result, not a thrown error.
47
+ *
48
+ * MCP draws that distinction deliberately: a protocol error is a fault the
49
+ * model cannot act on, while `isError: true` is a tool result the model reads
50
+ * and can respond to. An unservable query and a permission Connect refused are
51
+ * both things a model should see and adapt to - naming a field it may not
52
+ * filter on, or telling the user it cannot reach that data - so they come back
53
+ * as content rather than as a transport failure.
54
+ */
55
+ function asResult(text, isError = false) {
56
+ return { content: [{ type: "text", text }], isError };
57
+ }
58
+ export function createStoreToolset(options) {
59
+ const tools = deriveStoreTools(options);
60
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
61
+ return {
62
+ tools,
63
+ listTools: () => ({
64
+ tools: tools.map((tool) => ({
65
+ name: tool.name,
66
+ title: tool.title,
67
+ description: tool.description,
68
+ inputSchema: tool.inputSchema,
69
+ annotations: tool.annotations,
70
+ })),
71
+ }),
72
+ async callTool(name, args) {
73
+ const tool = byName.get(name);
74
+ if (!tool) {
75
+ return asResult(`No such tool: "${name}". Available: ${[...byName.keys()].join(", ")}.`, true);
76
+ }
77
+ try {
78
+ return asResult(JSON.stringify(await tool.execute(args), null, 2));
79
+ }
80
+ catch (error) {
81
+ return asResult(error instanceof Error ? error.message : String(error), true);
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,366 @@
1
+ import { storeIndexSignature } from "../store.js";
2
+ import { describeQueryRejection } from "../store-schema.js";
3
+ /**
4
+ * The store, as tools a model can call.
5
+ *
6
+ * ## Why a declaration is enough
7
+ *
8
+ * `Application.storeSchema` already answers every question a tool definition
9
+ * asks. It names the collection and says what it holds (`description`), which
10
+ * fields may be filtered or ordered on (`queryableFields`), which query shapes
11
+ * the database can actually serve (`indexes`), and whether the collection
12
+ * refuses everything but a create (`appendOnly`). Writing those tools by hand
13
+ * means maintaining a second copy of all of it, and the copy is wrong the first
14
+ * time a filter is added to a screen.
15
+ *
16
+ * So they are derived. Adding a queryable field to the declaration and
17
+ * re-seeding widens the tools on the next boot, with no tool code to touch.
18
+ *
19
+ * ## What this is not
20
+ *
21
+ * **It is not an authorization boundary.** Connect is. Every call here goes out
22
+ * with the end user's session token (`asUser`), and Connect resolves it to a
23
+ * member and checks the collection's declared `readPermission`/`writePermission`
24
+ * against that member's live roles - so a tool can never reach further than the
25
+ * person on whose behalf it runs, and a permission revoked in the console is
26
+ * gone from the next call. The shaping done here - omitting write tools for an
27
+ * `appendOnly` collection, refusing an unservable query - is there so a model
28
+ * fails fast and legibly, not because it protects anything. Connect refuses the
29
+ * same things independently.
30
+ *
31
+ * **There is no `scan` tool, deliberately.** `data.scan` reads a collection
32
+ * across every workspace and, for exactly that reason, takes no `asUser` - a
33
+ * workspace admin is an admin of their own workspace and not of a partner's
34
+ * other customers. A toolset that runs on behalf of a signed-in user therefore
35
+ * has nothing to derive from `crossWorkspace`, and generating a scan tool would
36
+ * mean running that one call under the application's own key while every tool
37
+ * beside it ran under the user's. Cross-tenant reads belong in a back-office
38
+ * surface with its own gate.
39
+ *
40
+ * **`kv` collections are skipped.** The key/value store is addressed by
41
+ * namespace and key rather than queried, so it derives nothing from
42
+ * `queryableFields` or `indexes` and the tools it wants are a different shape.
43
+ *
44
+ * ## Deriving over every collection is usually wrong
45
+ *
46
+ * Measured on the two reference applications: club's fifteen collections yield
47
+ * 45 read tools and 88 with writes on; CRM's seven yield 21 and 40. Those are
48
+ * whole-catalogue numbers, and a model offered them chooses worse than one
49
+ * offered a handful - the ceiling is tool-choice accuracy, which degrades well
50
+ * before a context window fills. Connect's own assistant bounds each turn to
51
+ * the capabilities the current screen promotes for exactly this reason.
52
+ *
53
+ * So `include` is the normal way to call this, not a refinement: name the
54
+ * collections the surface at hand is about. Nothing here enforces a cap,
55
+ * because the right number depends on the surface and a silent truncation
56
+ * would be worse than a large list.
57
+ */
58
+ /** Connect's own ceilings, mirrored so a tool can describe them before the call. */
59
+ const MAX_QUERY_LIMIT = 200;
60
+ const DEFAULT_QUERY_LIMIT = 50;
61
+ const QUERY_OPERATORS = [
62
+ "==",
63
+ "!=",
64
+ "<",
65
+ "<=",
66
+ ">",
67
+ ">=",
68
+ "in",
69
+ "not-in",
70
+ "array-contains",
71
+ ];
72
+ /**
73
+ * MCP tool names are `[a-z0-9_-]`; collection names additionally allow capitals.
74
+ * Lowercasing and folding `-` to `_` is what makes `dues-demands` addressable,
75
+ * and is also how two collections can collide - which is caught, not resolved,
76
+ * because silently renaming one would make the tool a model calls depend on
77
+ * declaration order.
78
+ */
79
+ function toolBaseName(collection) {
80
+ return collection.toLowerCase().replace(/-/g, "_");
81
+ }
82
+ /**
83
+ * How many index shapes are worth spelling out before the listing costs more
84
+ * than it teaches.
85
+ *
86
+ * A real declaration is not a handful. club's ledger declares around forty
87
+ * shapes, and writing all of them into both the list and aggregate tools was
88
+ * most of a 79 KB `tools/list` on its own - paid on every turn, for a listing a
89
+ * model reads poorly anyway. Past this many, the description states the rule
90
+ * and leaves the specifics to the refusal, which names the exact index a
91
+ * rejected query needed and costs no round trip to get.
92
+ */
93
+ const MAX_DESCRIBED_INDEXES = 12;
94
+ /** The servable query shapes, spelled out for the model rather than left to be discovered. */
95
+ function describeIndexes(collection) {
96
+ if (collection.indexes.length === 0) {
97
+ return "No composite indexes are declared, so a query may use at most one filter, ordered only by that same field.";
98
+ }
99
+ const preamble = "A query using more than one filter, or ordering by a field it does not filter on, must match a declared composite index";
100
+ if (collection.indexes.length > MAX_DESCRIBED_INDEXES) {
101
+ return `${preamble}. ${collection.indexes.length} are declared - too many to list here. Issue the query you want: if no index covers it you get back the exact index it would need, before anything is read.`;
102
+ }
103
+ const shapes = collection.indexes.map((index) => ` - ${storeIndexSignature(index.fields)}`).join("\n");
104
+ return `${preamble}:\n${shapes}`;
105
+ }
106
+ function readDescription(collection, verb) {
107
+ const head = `${verb} ${collection.name}. ${collection.description}`;
108
+ // Nothing declared queryable means the collection can only be read whole -
109
+ // which is the right shape for the one-document-per-key collections (a
110
+ // settings block, a counter), and saying so beats offering a filter argument
111
+ // that has no legal value.
112
+ if (collection.queryableFields.length === 0) {
113
+ return `${head}\n\nThis collection declares no queryable fields, so it cannot be filtered or ordered - it is read whole.`;
114
+ }
115
+ return [head, "", `Queryable fields: ${collection.queryableFields.join(", ")}.`, describeIndexes(collection)].join("\n");
116
+ }
117
+ const filtersSchema = (collection) => ({
118
+ type: "array",
119
+ description: "Filters, ANDed together.",
120
+ items: {
121
+ type: "object",
122
+ properties: {
123
+ field: { type: "string", enum: [...collection.queryableFields] },
124
+ op: { type: "string", enum: QUERY_OPERATORS },
125
+ value: {
126
+ description: "The value to compare against. Dates are ISO-8601 strings; a calendar day is YYYY-MM-DD.",
127
+ },
128
+ },
129
+ required: ["field", "op", "value"],
130
+ additionalProperties: false,
131
+ },
132
+ });
133
+ /**
134
+ * Documents come back with Connect's `_`-prefixed bookkeeping on them, and
135
+ * every write refuses those fields. Saying so on the write tools is what stops
136
+ * a model reading a document, editing one field and posting the whole thing
137
+ * back - which fails, correctly, but wastes a turn learning it.
138
+ */
139
+ const WRITE_DATA_NOTE = "Fields beginning with `_` are written by Connect and are refused here - strip `_createdAt`, `_updatedAt`, `_createdBy`, `_updatedBy` and `_updateTime` from a document you read before writing it back.";
140
+ function buildReadTools(collection, base, call, data, workspaceId) {
141
+ return [
142
+ {
143
+ name: `${base}_get`,
144
+ title: `Get one ${collection.name} document`,
145
+ description: `Read one document from ${collection.name} by id. ${collection.description}`,
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: { id: { type: "string", description: "The document id." } },
149
+ required: ["id"],
150
+ additionalProperties: false,
151
+ },
152
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
153
+ execute: (args) => call((asUser) => data.get({ workspaceId, collection: collection.name, id: String(args.id), asUser })),
154
+ },
155
+ {
156
+ name: `${base}_list`,
157
+ title: `List ${collection.name}`,
158
+ description: readDescription(collection, "List documents in"),
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ ...(collection.queryableFields.length > 0
163
+ ? {
164
+ filters: filtersSchema(collection),
165
+ orderBy: {
166
+ type: "object",
167
+ properties: {
168
+ field: { type: "string", enum: [...collection.queryableFields] },
169
+ direction: { type: "string", enum: ["asc", "desc"] },
170
+ },
171
+ required: ["field", "direction"],
172
+ additionalProperties: false,
173
+ },
174
+ }
175
+ : {}),
176
+ limit: {
177
+ type: "integer",
178
+ minimum: 1,
179
+ maximum: MAX_QUERY_LIMIT,
180
+ description: `Page size. Defaults to ${DEFAULT_QUERY_LIMIT}, caps at ${MAX_QUERY_LIMIT}.`,
181
+ },
182
+ cursor: {
183
+ type: "string",
184
+ description: "The previous page's nextCursor. A null nextCursor is the end of the collection.",
185
+ },
186
+ },
187
+ additionalProperties: false,
188
+ },
189
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
190
+ execute: async (args) => {
191
+ const filters = args.filters ?? [];
192
+ const orderBy = args.orderBy ?? null;
193
+ const rejection = describeQueryRejection(collection, { filters, orderBy });
194
+ if (rejection)
195
+ throw new Error(rejection);
196
+ return call((asUser) => data.query({
197
+ workspaceId,
198
+ collection: collection.name,
199
+ filters,
200
+ orderBy: orderBy ?? undefined,
201
+ limit: args.limit,
202
+ cursor: args.cursor ?? null,
203
+ asUser,
204
+ }));
205
+ },
206
+ },
207
+ {
208
+ name: `${base}_aggregate`,
209
+ title: `Count, sum or average ${collection.name}`,
210
+ description: [
211
+ readDescription(collection, "Aggregate"),
212
+ "",
213
+ "Counts, sums and averages are computed in the database rather than by reading rows back, so prefer this over listing a collection to add it up. A sum or average over nothing answers null, not 0.",
214
+ ].join("\n"),
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ aggregations: {
219
+ type: "array",
220
+ minItems: 1,
221
+ items: {
222
+ type: "object",
223
+ properties: {
224
+ op: {
225
+ type: "string",
226
+ // `sum` and `avg` need a field, and every field they could
227
+ // name must be queryable - so a collection declaring none can
228
+ // only be counted.
229
+ enum: collection.queryableFields.length > 0 ? ["count", "sum", "avg"] : ["count"],
230
+ },
231
+ ...(collection.queryableFields.length > 0
232
+ ? {
233
+ field: {
234
+ type: "string",
235
+ enum: [...collection.queryableFields],
236
+ description: "Required for sum and avg; omitted for count, which counts documents.",
237
+ },
238
+ }
239
+ : {}),
240
+ alias: { type: "string", description: "What the result is keyed by." },
241
+ },
242
+ required: ["op", "alias"],
243
+ additionalProperties: false,
244
+ },
245
+ },
246
+ ...(collection.queryableFields.length > 0 ? { filters: filtersSchema(collection) } : {}),
247
+ },
248
+ required: ["aggregations"],
249
+ additionalProperties: false,
250
+ },
251
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
252
+ execute: async (args) => {
253
+ const filters = args.filters ?? [];
254
+ // An aggregation has no ordering, so the index it needs is the one its
255
+ // filters need - the same check `list` makes, minus the sort.
256
+ const rejection = describeQueryRejection(collection, { filters, orderBy: null });
257
+ if (rejection)
258
+ throw new Error(rejection);
259
+ return call((asUser) => data.aggregate({
260
+ workspaceId,
261
+ collection: collection.name,
262
+ aggregations: args.aggregations,
263
+ filters,
264
+ asUser,
265
+ }));
266
+ },
267
+ },
268
+ ];
269
+ }
270
+ function buildWriteTools(collection, base, call, data, workspaceId, writes) {
271
+ if (writes === "none")
272
+ return [];
273
+ const tools = [
274
+ {
275
+ name: `${base}_create`,
276
+ title: `Create a ${collection.name} document`,
277
+ description: `Create a document in ${collection.name}. ${collection.description}\n\n${WRITE_DATA_NOTE}`,
278
+ inputSchema: {
279
+ type: "object",
280
+ properties: {
281
+ data: { type: "object", description: "The document's fields." },
282
+ id: { type: "string", description: "Choose the id, or omit for an allocated one. An existing id is a conflict." },
283
+ },
284
+ required: ["data"],
285
+ additionalProperties: false,
286
+ },
287
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
288
+ execute: (args) => call((asUser) => data.create({
289
+ workspaceId,
290
+ collection: collection.name,
291
+ data: args.data,
292
+ id: args.id,
293
+ asUser,
294
+ })),
295
+ },
296
+ ];
297
+ // An append-only collection refuses a merge and a delete for every caller,
298
+ // this application's own key included, so deriving those tools would offer a
299
+ // model two calls that can only ever fail.
300
+ if (writes === "all" && !collection.appendOnly) {
301
+ tools.push({
302
+ name: `${base}_update`,
303
+ title: `Update a ${collection.name} document`,
304
+ description: `Merge top-level fields into an existing ${collection.name} document. Fields not named are left alone.\n\n${WRITE_DATA_NOTE}`,
305
+ inputSchema: {
306
+ type: "object",
307
+ properties: {
308
+ id: { type: "string" },
309
+ data: { type: "object", description: "The fields to merge." },
310
+ },
311
+ required: ["id", "data"],
312
+ additionalProperties: false,
313
+ },
314
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
315
+ execute: (args) => call((asUser) => data.update({
316
+ workspaceId,
317
+ collection: collection.name,
318
+ id: String(args.id),
319
+ data: args.data,
320
+ asUser,
321
+ })),
322
+ }, {
323
+ name: `${base}_delete`,
324
+ title: `Delete a ${collection.name} document`,
325
+ description: `Delete one document from ${collection.name}. Idempotent - deleting a document that is not there succeeds.`,
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: { id: { type: "string" } },
329
+ required: ["id"],
330
+ additionalProperties: false,
331
+ },
332
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
333
+ execute: (args) => call((asUser) => data
334
+ .delete({ workspaceId, collection: collection.name, id: String(args.id), asUser })
335
+ .then(() => ({ deleted: String(args.id) }))),
336
+ });
337
+ }
338
+ return tools;
339
+ }
340
+ /** Every tool one declaration yields, in a stable order. */
341
+ export function deriveStoreTools(options) {
342
+ const { collections, data, workspaceId, writes = "none", prefix = "", include } = options;
343
+ const resolveUser = typeof options.asUser === "function" ? options.asUser : () => options.asUser;
344
+ const call = async (run) => run(await resolveUser());
345
+ const wanted = include ? new Set(include) : null;
346
+ const selected = collections.filter((collection) => collection.kind === "data" && (!wanted || wanted.has(collection.name)));
347
+ if (wanted) {
348
+ const declared = new Set(collections.map((collection) => collection.name));
349
+ const missing = [...wanted].filter((name) => !declared.has(name));
350
+ if (missing.length > 0) {
351
+ throw new Error(`No declaration for ${missing.join(", ")}. Declared collections: ${[...declared].join(", ")}.`);
352
+ }
353
+ }
354
+ const byBaseName = new Map();
355
+ const tools = [];
356
+ for (const collection of selected) {
357
+ const base = `${prefix}${toolBaseName(collection.name)}`;
358
+ const clash = byBaseName.get(base);
359
+ if (clash) {
360
+ throw new Error(`"${collection.name}" and "${clash}" both derive the tool prefix "${base}". Rename one, or give one its own toolset with a prefix.`);
361
+ }
362
+ byBaseName.set(base, collection.name);
363
+ tools.push(...buildReadTools(collection, base, call, data, workspaceId), ...buildWriteTools(collection, base, call, data, workspaceId, writes));
364
+ }
365
+ return tools;
366
+ }
@@ -138,4 +138,4 @@ export class ConnectClient {
138
138
  }
139
139
  }
140
140
  /** Kept in step with `package.json` by `sdk.test.ts`. */
141
- export const SDK_VERSION = "0.14.0";
141
+ export const SDK_VERSION = "0.16.1";
@@ -331,6 +331,13 @@ export function createConnectHandler(options) {
331
331
  const upstream = await client.ai.chat({
332
332
  workspaceId: workspaceId,
333
333
  messages: body.messages,
334
+ // Forwarded unvalidated on purpose. These are your application's own
335
+ // description of itself and of where the user is - Connect bounds and
336
+ // sanitises them on arrival, and uses them only to write the answer and
337
+ // to validate action links. Nothing here reaches an authorization
338
+ // decision, so there is nothing for this layer to check more strictly.
339
+ screenContext: body.screenContext,
340
+ screens: body.screens,
334
341
  // Connect re-derives the workspace from this token, so the line above
335
342
  // is belt to its braces - the browser cannot reach another tenant's
336
343
  // Context Index even if this route were changed to trust the request.
@@ -27,7 +27,14 @@ export class AiResource {
27
27
  // it from the header. Passing a signed-in user's session token makes
28
28
  // Connect derive the workspace from *that* rather than from
29
29
  // `workspaceId`, which is what the bridge relies on.
30
- json: { workspaceId: input.workspaceId, messages: input.messages },
30
+ json: {
31
+ workspaceId: input.workspaceId,
32
+ messages: input.messages,
33
+ // Omitted entirely when absent, rather than sent as `undefined` - an
34
+ // application that describes no screens should send no field.
35
+ ...(input.screens ? { screens: input.screens } : {}),
36
+ ...(input.screenContext ? { screenContext: input.screenContext } : {}),
37
+ },
31
38
  headers: actingAs(input.asUser),
32
39
  raw: true,
33
40
  signal: options.signal,