@atmos.build/ui 0.1.3 → 0.1.5

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 (43) hide show
  1. package/README.md +6 -6
  2. package/dist/components/calendar.js +6 -5
  3. package/dist/components/charts/bar-chart.js +4 -2
  4. package/dist/components/charts/colors.js +1 -1
  5. package/dist/components/charts/index.d.ts +2 -2
  6. package/dist/components/charts/index.js +1 -1
  7. package/dist/components/charts/layout.d.ts +2 -1
  8. package/dist/components/charts/layout.js +15 -8
  9. package/dist/components/charts/pie-chart.d.ts +10 -1
  10. package/dist/components/charts/pie-chart.js +34 -9
  11. package/dist/components/code-editor-theme.d.ts +1 -1
  12. package/dist/components/code-editor-theme.js +1 -1
  13. package/dist/components/date-time-picker.js +13 -4
  14. package/dist/components/inline-alert.d.ts +6 -1
  15. package/dist/components/inline-alert.js +20 -3
  16. package/dist/components/metric-grid.d.ts +5 -1
  17. package/dist/components/metric-grid.js +19 -8
  18. package/dist/components/property-selector.js +9 -29
  19. package/dist/components/shimmer-text.js +1 -1
  20. package/dist/components/time-column.js +3 -1
  21. package/dist/components/usage-bar.d.ts +3 -1
  22. package/dist/components/usage-bar.js +5 -3
  23. package/dist/flow/editor/adapter.d.ts +1 -1
  24. package/dist/flow/editor/flow-editor.d.ts +2 -2
  25. package/dist/flow/editor/flow-editor.js +4 -4
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +2 -1
  28. package/dist/lib/scroll-lock-escape.d.ts +9 -0
  29. package/dist/lib/scroll-lock-escape.js +22 -0
  30. package/dist/mcp-app/checkout-version.d.ts +14 -0
  31. package/dist/mcp-app/checkout-version.js +19 -0
  32. package/dist/mcp-app/index.d.ts +4 -0
  33. package/dist/mcp-app/index.js +3 -0
  34. package/dist/mcp-app/resource-protocol.d.ts +5 -0
  35. package/dist/mcp-app/resource-protocol.js +13 -0
  36. package/dist/mcp-app/typed-schema.d.ts +28 -0
  37. package/dist/mcp-app/typed-schema.js +1 -0
  38. package/dist/mcp-app/use-mcp-resource.d.ts +11 -0
  39. package/dist/mcp-app/use-mcp-resource.js +206 -0
  40. package/dist/mcp-app/use-mcp-tool.d.ts +9 -0
  41. package/dist/mcp-app/use-mcp-tool.js +73 -0
  42. package/dist/mcp-app/vite.js +1 -1
  43. package/package.json +2 -1
@@ -9,9 +9,11 @@ export interface UsageBarProps extends Omit<React.ComponentProps<'div'>, 'childr
9
9
  label: string;
10
10
  /** Consumed share, 0–1. Values outside the range clamp rather than overflow the track. */
11
11
  fraction: number;
12
+ pendingFraction?: number;
12
13
  /** Right-aligned readout — a percentage, "12k of 200k", whatever the caller measures in. */
13
14
  value?: React.ReactNode;
14
15
  caption?: React.ReactNode;
15
16
  tone?: UsageBarTone;
17
+ srOnlyLabel?: boolean;
16
18
  }
17
- export declare function UsageBar({ label, fraction, value, caption, tone, className, ...props }: UsageBarProps): React.JSX.Element;
19
+ export declare function UsageBar({ label, fraction, pendingFraction, value, caption, tone, srOnlyLabel, className, ...props }: UsageBarProps): React.JSX.Element;
@@ -18,9 +18,11 @@ export function usageTone(fraction) {
18
18
  return 'warning';
19
19
  return 'usage';
20
20
  }
21
- export function UsageBar({ label, fraction, value, caption, tone, className, ...props }) {
21
+ export function UsageBar({ label, fraction, pendingFraction = 0, value, caption, tone, srOnlyLabel = false, className, ...props }) {
22
22
  const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
23
23
  const percent = Math.round(clamped * 100);
24
- const resolvedTone = tone ?? usageTone(clamped);
25
- return (_jsxs("div", { "data-slot": "usage-bar", "data-tone": resolvedTone, role: "meter", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "aria-label": label, className: cn('flex flex-col gap-1', className), ...props, children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-[11.5px]", children: [_jsx("span", { className: "text-muted-foreground min-w-0 truncate", children: label }), value ? (_jsx("span", { className: "text-muted-foreground shrink-0 font-mono tabular-nums", children: value })) : null] }), _jsx("div", { className: "bg-muted h-1 overflow-hidden rounded-full", "aria-hidden": true, children: _jsx("span", { className: cn('block h-full rounded-full transition-[width] duration-150 ease-[var(--ease-out-expo)] motion-reduce:transition-none', TONE_FILL[resolvedTone]), style: { width: `${percent}%` } }) }), caption ? (_jsx("p", { className: "text-muted-foreground/75 font-mono text-[10.5px]", children: caption })) : null] }));
24
+ const pending = Math.max(0, Math.min(1 - clamped, Number.isFinite(pendingFraction) ? pendingFraction : 0));
25
+ const pendingPercent = Math.round(pending * 100);
26
+ const resolvedTone = tone ?? usageTone(clamped + pending);
27
+ return (_jsxs("div", { "data-slot": "usage-bar", "data-tone": resolvedTone, role: "meter", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.min(100, percent + pendingPercent), "aria-label": label, className: cn('flex flex-col gap-1', className), ...props, children: [srOnlyLabel && !value ? null : (_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-[11.5px]", children: [srOnlyLabel ? null : (_jsx("span", { className: "text-muted-foreground min-w-0 truncate", children: label })), value ? (_jsx("span", { className: "text-muted-foreground ml-auto shrink-0 font-mono tabular-nums", children: value })) : null] })), _jsxs("div", { className: "bg-muted flex h-1 overflow-hidden rounded-full", "aria-hidden": true, children: [_jsx("span", { className: cn('block h-full transition-[width] duration-150 ease-[var(--ease-out-expo)] motion-reduce:transition-none', TONE_FILL[resolvedTone]), style: { width: `${percent}%` } }), pendingPercent > 0 ? (_jsx("span", { "data-slot": "usage-bar-pending", className: cn('block h-full opacity-40 transition-[width] duration-150 ease-[var(--ease-out-expo)] motion-reduce:transition-none', TONE_FILL[resolvedTone]), style: { width: `${pendingPercent}%` } })) : null] }), caption ? (_jsx("p", { className: "text-muted-foreground/75 font-mono text-[10.5px]", children: caption })) : null] }));
26
28
  }
@@ -10,7 +10,7 @@ export interface FlowEditorIssue {
10
10
  readonly severity?: FlowEditorIssueSeverity;
11
11
  }
12
12
  export interface FlowAdapterContext {
13
- readonly workspaceId: string | null | undefined;
13
+ readonly orgId: string | null | undefined;
14
14
  }
15
15
  export interface NodeConfigPanelProps<TData> {
16
16
  readonly node: RfNode<TData>;
@@ -23,7 +23,7 @@ export interface FlowEditorCanvasContext<TData, TEdge> {
23
23
  export type UseFlowEditorCanvasPresentation<TData, TEdge> = (context: FlowEditorCanvasContext<TData, TEdge>) => FlowEditorCanvasPresentation<TData, TEdge>;
24
24
  export interface FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult> {
25
25
  readonly adapter: FlowDomainAdapter<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>;
26
- readonly workspaceId: string | null | undefined;
26
+ readonly orgId: string | null | undefined;
27
27
  readonly initialNodes: RfNode<TData>[];
28
28
  readonly initialEdges: RfEdge<TEdge>[];
29
29
  readonly settingsTitle: string;
@@ -50,5 +50,5 @@ export interface FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TP
50
50
  onOpenChange: (open: boolean) => void;
51
51
  }) => ReactNode;
52
52
  }
53
- export declare function FlowEditor<TData, TEdge = unknown, TDef = unknown, TSaveInput = unknown, TSaveResult = unknown, TPublishInput = unknown, TPublishResult = unknown>({ adapter, workspaceId, initialNodes, initialEdges, settingsTitle, isNew, externalDirty, initiallyUnpublished, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId, renderHeaderStart, useCanvasPresentation, renderSettings, }: FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>): import("react").JSX.Element;
53
+ export declare function FlowEditor<TData, TEdge = unknown, TDef = unknown, TSaveInput = unknown, TSaveResult = unknown, TPublishInput = unknown, TPublishResult = unknown>({ adapter, orgId, initialNodes, initialEdges, settingsTitle, isNew, externalDirty, initiallyUnpublished, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId, renderHeaderStart, useCanvasPresentation, renderSettings, }: FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>): import("react").JSX.Element;
54
54
  export {};
@@ -14,9 +14,9 @@ import { SettingsDialog } from './settings-dialog.js';
14
14
  function useDefaultCanvasPresentation(_context) {
15
15
  return {};
16
16
  }
17
- export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, settingsTitle, isNew = false, externalDirty = false, initiallyUnpublished = false, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId = 'flow-editor', renderHeaderStart, useCanvasPresentation = useDefaultCanvasPresentation, renderSettings, }) {
17
+ export function FlowEditor({ adapter, orgId, initialNodes, initialEdges, settingsTitle, isNew = false, externalDirty = false, initiallyUnpublished = false, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId = 'flow-editor', renderHeaderStart, useCanvasPresentation = useDefaultCanvasPresentation, renderSettings, }) {
18
18
  const t = useTranslations('flowEditor');
19
- const adapterCtx = useMemo(() => ({ workspaceId }), [workspaceId]);
19
+ const adapterCtx = useMemo(() => ({ orgId }), [orgId]);
20
20
  const groups = useMemo(() => adapter.nodeGroups(adapterCtx), [adapter, adapterCtx]);
21
21
  const dag = useDagEditor({
22
22
  makeNodeData: adapter.makeNodeData,
@@ -173,7 +173,7 @@ export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, s
173
173
  lastSavedSigRef.current = debouncedSaveSig;
174
174
  return;
175
175
  }
176
- if (isNew || !workspaceId)
176
+ if (isNew || !orgId)
177
177
  return;
178
178
  if (debouncedSaveSig === lastSavedSigRef.current)
179
179
  return;
@@ -181,7 +181,7 @@ export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, s
181
181
  void persistRef.current(true).catch(() => {
182
182
  lastSavedSigRef.current = null;
183
183
  });
184
- }, [debouncedSaveSig, isNew, workspaceId]);
184
+ }, [debouncedSaveSig, isNew, orgId]);
185
185
  const [settingsOpen, setSettingsOpen] = useState(false);
186
186
  const toolbarExtras = adapter.toolbarExtras?.(adapterCtx);
187
187
  const canvasPresentation = useCanvasPresentation({ dag, groups, invalidNodeIds });
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { AudioPreview, type AudioPreviewProps, type AudioPreviewLabels, type Aud
4
4
  export { AvatarUploader, type AvatarUploaderLabels, type AvatarUploaderProps, } from './components/avatar-uploader.js';
5
5
  export { Avatar, avatarVariants, initialsFromName, type AvatarProps } from './components/avatar.js';
6
6
  export { Badge, badgeVariants } from './components/badge.js';
7
- export { AreaChart, BarChart, ChartLegend, LineChart, PieChart, RadarChart, RadialChart, ScatterChart, chartColor, type BarChartProps, type CartesianChartProps, type CategoricalChartProps, type ChartAxisOptions, type ChartDatum, type ChartLegendItem, type ScatterChartProps, } from './components/charts/index.js';
7
+ export { AreaChart, BarChart, CHART_COLORS, ChartLegend, LineChart, PieChart, RadarChart, RadialChart, ScatterChart, chartColor, type BarChartProps, type CartesianChartProps, type CategoricalChartProps, type ChartAxisOptions, type ChartDatum, type ChartLegendItem, type PieChartProps, type ScatterChartProps, } from './components/charts/index.js';
8
8
  export { ChannelBadge, ChannelListRow, channelIcon, channelListRowVariants, type ChannelBadgeProps, type ChannelListRowProps, type ChannelKind, } from './components/channel-list.js';
9
9
  export { Button, buttonVariants } from './components/button.js';
10
10
  export { Chip, chipVariants, type ChipProps, type ChipLabelProps, type ChipRemoveProps, } from './components/chip.js';
@@ -75,6 +75,7 @@ export { TimestampedTranscript, type TimestampedTranscriptLabels, type Timestamp
75
75
  export { MicrophonePriority, type MicrophonePriorityItem, type MicrophonePriorityLabels, type MicrophonePriorityProps, } from './components/microphone-priority.js';
76
76
  export { SpeechPlayback, type SpeechPlaybackLabels, type SpeechPlaybackProps, type SpeechPlaybackState, } from './components/speech-playback.js';
77
77
  export { cn } from './lib/cn.js';
78
+ export { useScrollLockEscape } from './lib/scroll-lock-escape.js';
78
79
  export { clearLocalUiState, readLocalUiState, subscribeLocalUiState, useLocalUiState, writeLocalUiState, } from './lib/local-ui-state.js';
79
80
  export { disclosureStateKey, useDisclosureState, type DisclosureState, } from './lib/disclosure-state.js';
80
81
  export * from './lib/view-query/index.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { AudioPreview, } from './components/audio-preview.js';
4
4
  export { AvatarUploader, } from './components/avatar-uploader.js';
5
5
  export { Avatar, avatarVariants, initialsFromName } from './components/avatar.js';
6
6
  export { Badge, badgeVariants } from './components/badge.js';
7
- export { AreaChart, BarChart, ChartLegend, LineChart, PieChart, RadarChart, RadialChart, ScatterChart, chartColor, } from './components/charts/index.js';
7
+ export { AreaChart, BarChart, CHART_COLORS, ChartLegend, LineChart, PieChart, RadarChart, RadialChart, ScatterChart, chartColor, } from './components/charts/index.js';
8
8
  export { ChannelBadge, ChannelListRow, channelIcon, channelListRowVariants, } from './components/channel-list.js';
9
9
  export { Button, buttonVariants } from './components/button.js';
10
10
  export { Chip, chipVariants, } from './components/chip.js';
@@ -75,6 +75,7 @@ export { TimestampedTranscript, } from './components/timestamped-transcript.js';
75
75
  export { MicrophonePriority, } from './components/microphone-priority.js';
76
76
  export { SpeechPlayback, } from './components/speech-playback.js';
77
77
  export { cn } from './lib/cn.js';
78
+ export { useScrollLockEscape } from './lib/scroll-lock-escape.js';
78
79
  export { clearLocalUiState, readLocalUiState, subscribeLocalUiState, useLocalUiState, writeLocalUiState, } from './lib/local-ui-state.js';
79
80
  export { disclosureStateKey, useDisclosureState, } from './lib/disclosure-state.js';
80
81
  export * from './lib/view-query/index.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Portalled content lands outside the scroll-lock (react-remove-scroll) of any enclosing dialog.
3
+ * That lock's document-level, bubble-phase `wheel` handler cancels mouse-wheel scrolling on
4
+ * portalled content (trackpad deltas slip its overscroll heuristic, discrete wheel ticks don't).
5
+ * Stopping the wheel event at the scroller keeps it from reaching that handler, so the content
6
+ * scrolls natively while the dialog's background stays locked. A callback ref (re)attaches the
7
+ * listener every time the scroller mounts, since it only exists while the surface is open.
8
+ */
9
+ export declare function useScrollLockEscape<T extends HTMLElement>(): (node: T | null) => void;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { useCallback, useRef } from 'react';
3
+ /**
4
+ * Portalled content lands outside the scroll-lock (react-remove-scroll) of any enclosing dialog.
5
+ * That lock's document-level, bubble-phase `wheel` handler cancels mouse-wheel scrolling on
6
+ * portalled content (trackpad deltas slip its overscroll heuristic, discrete wheel ticks don't).
7
+ * Stopping the wheel event at the scroller keeps it from reaching that handler, so the content
8
+ * scrolls natively while the dialog's background stays locked. A callback ref (re)attaches the
9
+ * listener every time the scroller mounts, since it only exists while the surface is open.
10
+ */
11
+ export function useScrollLockEscape() {
12
+ const cleanupRef = useRef(null);
13
+ return useCallback((node) => {
14
+ cleanupRef.current?.();
15
+ cleanupRef.current = null;
16
+ if (!node)
17
+ return;
18
+ const stop = (event) => event.stopPropagation();
19
+ node.addEventListener('wheel', stop, { passive: true });
20
+ cleanupRef.current = () => node.removeEventListener('wheel', stop);
21
+ }, []);
22
+ }
@@ -0,0 +1,14 @@
1
+ export interface McpAppCheckoutVersionState {
2
+ reachable: boolean;
3
+ repositoryRoot: boolean;
4
+ branch: string | null;
5
+ headCommit: string | null;
6
+ detached: boolean;
7
+ }
8
+ export interface McpAppCheckoutVersionCopy {
9
+ checkout(id: string): string;
10
+ detachedHead: string;
11
+ detachedHeadAt(commit: string): string;
12
+ }
13
+ export declare function mcpAppCheckoutVersionLabel(checkoutId: string, state: McpAppCheckoutVersionState | null, copy: McpAppCheckoutVersionCopy): string;
14
+ export declare function shortCheckoutId(checkoutId: string): string;
@@ -0,0 +1,19 @@
1
+ export function mcpAppCheckoutVersionLabel(checkoutId, state, copy) {
2
+ if (state?.reachable && state.repositoryRoot) {
3
+ if (!state.detached && state.branch?.trim())
4
+ return state.branch.trim();
5
+ if (state.detached) {
6
+ const commit = shortCommit(state.headCommit);
7
+ return commit ? copy.detachedHeadAt(commit) : copy.detachedHead;
8
+ }
9
+ }
10
+ return copy.checkout(shortCheckoutId(checkoutId));
11
+ }
12
+ export function shortCheckoutId(checkoutId) {
13
+ const normalized = checkoutId.trim();
14
+ return normalized.length > 12 ? `${normalized.slice(0, 4)}…${normalized.slice(-4)}` : normalized;
15
+ }
16
+ function shortCommit(commit) {
17
+ const normalized = commit?.trim();
18
+ return normalized ? normalized.slice(0, 7) : null;
19
+ }
@@ -1,4 +1,8 @@
1
1
  export { App } from '@modelcontextprotocol/ext-apps';
2
2
  export type { McpUiHostContext, McpUiToolResultNotification } from '@modelcontextprotocol/ext-apps';
3
3
  export { applyAtmosHostContext } from './theme.js';
4
+ export { mcpAppCheckoutVersionLabel, shortCheckoutId, type McpAppCheckoutVersionCopy, type McpAppCheckoutVersionState, } from './checkout-version.js';
4
5
  export { useAtmosMcpApp, type AtmosMcpAppOptions } from './use-atmos-mcp-app.js';
6
+ export { useMcpResource, type McpResourceState } from './use-mcp-resource.js';
7
+ export { callMcpTool, useMcpTool, type McpToolState } from './use-mcp-tool.js';
8
+ export type { McpToolInput, McpToolOutput, RuntimeSchema, TypedMcpResource, TypedMcpTool, } from './typed-schema.js';
@@ -1,3 +1,6 @@
1
1
  export { App } from '@modelcontextprotocol/ext-apps';
2
2
  export { applyAtmosHostContext } from './theme.js';
3
+ export { mcpAppCheckoutVersionLabel, shortCheckoutId, } from './checkout-version.js';
3
4
  export { useAtmosMcpApp } from './use-atmos-mcp-app.js';
5
+ export { useMcpResource } from './use-mcp-resource.js';
6
+ export { callMcpTool, useMcpTool } from './use-mcp-tool.js';
@@ -0,0 +1,5 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import { type ResourceUpdatedNotification } from '@modelcontextprotocol/sdk/types.js';
3
+ export declare function onMcpResourceUpdated(app: App, handler: (notification: ResourceUpdatedNotification) => void): void;
4
+ export declare function subscribeMcpResource(app: App, uri: string): Promise<void>;
5
+ export declare function unsubscribeMcpResource(app: App, uri: string): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { EmptyResultSchema, ResourceUpdatedNotificationSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
2
+ export function onMcpResourceUpdated(app, handler) {
3
+ resourceProtocol(app).setNotificationHandler(ResourceUpdatedNotificationSchema, handler);
4
+ }
5
+ export async function subscribeMcpResource(app, uri) {
6
+ await resourceProtocol(app).request(SubscribeRequestSchema.parse({ method: 'resources/subscribe', params: { uri } }), EmptyResultSchema);
7
+ }
8
+ export async function unsubscribeMcpResource(app, uri) {
9
+ await resourceProtocol(app).request(UnsubscribeRequestSchema.parse({ method: 'resources/unsubscribe', params: { uri } }), EmptyResultSchema);
10
+ }
11
+ function resourceProtocol(app) {
12
+ return app;
13
+ }
@@ -0,0 +1,28 @@
1
+ export interface RuntimeSchema<T> {
2
+ parse(value: unknown): T;
3
+ }
4
+ export interface TypedMcpResource<T> {
5
+ readonly uri: string;
6
+ readonly schema: RuntimeSchema<T>;
7
+ }
8
+ export interface TypedMcpTool<ParsedInput = unknown, Output = unknown, Input = ParsedInput> {
9
+ readonly name: string;
10
+ readonly input: RuntimeSchema<ParsedInput>;
11
+ readonly output: RuntimeSchema<Output>;
12
+ readonly '~types'?: {
13
+ input: Input;
14
+ output: Output;
15
+ parsedInput: ParsedInput;
16
+ };
17
+ }
18
+ export type McpToolInput<Tool extends TypedMcpTool> = '~types' extends keyof Tool ? ToolTypeCarrier<Tool> extends {
19
+ input: infer Input;
20
+ } ? Input : ParsedSchema<Tool['input']> : ParsedSchema<Tool['input']>;
21
+ export type McpToolOutput<Tool extends TypedMcpTool> = '~types' extends keyof Tool ? ToolTypeCarrier<Tool> extends {
22
+ output: infer Output;
23
+ } ? Output : ParsedSchema<Tool['output']> : ParsedSchema<Tool['output']>;
24
+ type ToolTypeCarrier<Tool> = Tool extends {
25
+ readonly '~types'?: infer Types;
26
+ } ? NonNullable<Types> : never;
27
+ type ParsedSchema<Schema> = Schema extends RuntimeSchema<infer Value> ? Value : never;
28
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import type { TypedMcpResource } from './typed-schema.js';
4
+ export interface McpResourceState<T> {
5
+ data: T | undefined;
6
+ error: Error | undefined;
7
+ loading: boolean;
8
+ refresh: () => void;
9
+ }
10
+ export declare function useMcpResource<T>(app: App | null | undefined, uri: string | null | undefined, select: (resource: ReadResourceResult) => T): McpResourceState<T>;
11
+ export declare function useMcpResource<T>(app: App | null | undefined, resource: TypedMcpResource<T> | null | undefined): McpResourceState<T>;
@@ -0,0 +1,206 @@
1
+ /* eslint-disable react-hooks/refs -- the latest selector must not become a store subscription dependency */
2
+ import { useMemo, useRef, useSyncExternalStore } from 'react';
3
+ import { onMcpResourceUpdated, subscribeMcpResource, unsubscribeMcpResource, } from './resource-protocol.js';
4
+ const EMPTY_SNAPSHOT = { loading: false };
5
+ const SUBSCRIPTION_RETRY_BASE_MS = 500;
6
+ const SUBSCRIPTION_RETRY_MAX_ATTEMPT = 6;
7
+ const SUBSCRIPTION_RETRY_MAX_MS = 30_000;
8
+ const registries = new WeakMap();
9
+ export function useMcpResource(app, resourceOrUri, select) {
10
+ const uri = typeof resourceOrUri === 'string' ? resourceOrUri : resourceOrUri?.uri;
11
+ const selectRef = useRef(() => {
12
+ throw new Error('An MCP resource selector is required');
13
+ });
14
+ selectRef.current =
15
+ typeof resourceOrUri === 'string'
16
+ ? (select ?? selectRef.current)
17
+ : (result) => parseTypedResource(result, resourceOrUri);
18
+ const entry = useMemo(() => (app && uri ? resourceEntry(app, uri) : undefined), [app, uri]);
19
+ const snapshot = useSyncExternalStore(entry?.subscribe ?? emptySubscribe, entry?.getSnapshot ?? emptySnapshot, emptySnapshot);
20
+ let data;
21
+ let error = snapshot.error;
22
+ if (snapshot.result) {
23
+ try {
24
+ data = selectRef.current(snapshot.result);
25
+ }
26
+ catch (cause) {
27
+ error = resourceError(cause);
28
+ }
29
+ }
30
+ return {
31
+ data,
32
+ error,
33
+ loading: snapshot.loading,
34
+ refresh: entry?.refresh ?? emptyRefresh,
35
+ };
36
+ }
37
+ function parseTypedResource(result, resource) {
38
+ if (!resource)
39
+ throw new Error('An MCP resource descriptor is required');
40
+ const content = result.contents.find((item) => item.uri === resource.uri);
41
+ if (!content || !('text' in content) || typeof content.text !== 'string') {
42
+ throw new Error(`MCP resource ${resource.uri} did not contain text content`);
43
+ }
44
+ return resource.schema.parse(JSON.parse(content.text));
45
+ }
46
+ class McpResourceRegistry {
47
+ app;
48
+ entries = new Map();
49
+ listening = false;
50
+ constructor(app) {
51
+ this.app = app;
52
+ }
53
+ listen() {
54
+ if (this.listening)
55
+ return;
56
+ this.listening = true;
57
+ onMcpResourceUpdated(this.app, ({ params }) => this.entries.get(params.uri)?.updated());
58
+ }
59
+ }
60
+ class McpResourceEntry {
61
+ registry;
62
+ uri;
63
+ listeners = new Set();
64
+ snapshot = { loading: true };
65
+ desired = false;
66
+ subscribed = false;
67
+ readVersion = 0;
68
+ retryAttempt = 0;
69
+ retryTimer;
70
+ transition = Promise.resolve();
71
+ constructor(registry, uri) {
72
+ this.registry = registry;
73
+ this.uri = uri;
74
+ }
75
+ getSnapshot = () => this.snapshot;
76
+ subscribe = (listener) => {
77
+ this.listeners.add(listener);
78
+ if (this.listeners.size === 1) {
79
+ const wasDesired = this.desired;
80
+ this.desired = true;
81
+ if (!wasDesired) {
82
+ this.retryAttempt = 0;
83
+ this.cancelRetry();
84
+ }
85
+ this.registry.listen();
86
+ this.reconcile();
87
+ }
88
+ return () => {
89
+ this.listeners.delete(listener);
90
+ queueMicrotask(() => {
91
+ if (this.listeners.size !== 0)
92
+ return;
93
+ this.desired = false;
94
+ this.cancelRetry();
95
+ this.reconcile();
96
+ });
97
+ };
98
+ };
99
+ refresh = () => {
100
+ if (this.subscribed) {
101
+ void this.read();
102
+ return;
103
+ }
104
+ this.retryAttempt = 0;
105
+ this.cancelRetry();
106
+ this.reconcile();
107
+ };
108
+ updated() {
109
+ if (this.desired && this.subscribed)
110
+ void this.read();
111
+ }
112
+ reconcile() {
113
+ this.transition = this.transition
114
+ .then(() => this.applyDesired())
115
+ .catch((cause) => {
116
+ if (this.desired) {
117
+ this.publish({ ...this.snapshot, error: resourceError(cause), loading: false });
118
+ if (retryableResourceError(cause))
119
+ this.scheduleRetry();
120
+ }
121
+ });
122
+ }
123
+ async applyDesired() {
124
+ if (this.desired && !this.subscribed) {
125
+ this.publish({ ...this.snapshot, error: undefined, loading: true });
126
+ await subscribeMcpResource(this.registry.app, this.uri);
127
+ this.subscribed = true;
128
+ this.retryAttempt = 0;
129
+ this.cancelRetry();
130
+ if (this.desired)
131
+ void this.read();
132
+ }
133
+ if (!this.desired && this.subscribed) {
134
+ this.subscribed = false;
135
+ this.readVersion += 1;
136
+ await unsubscribeMcpResource(this.registry.app, this.uri);
137
+ }
138
+ if (this.desired !== this.subscribed)
139
+ this.reconcile();
140
+ }
141
+ scheduleRetry() {
142
+ if (!this.desired || this.subscribed || this.retryTimer)
143
+ return;
144
+ const delay = Math.min(SUBSCRIPTION_RETRY_BASE_MS * 2 ** this.retryAttempt, SUBSCRIPTION_RETRY_MAX_MS);
145
+ this.retryAttempt = Math.min(this.retryAttempt + 1, SUBSCRIPTION_RETRY_MAX_ATTEMPT);
146
+ this.retryTimer = setTimeout(() => {
147
+ this.retryTimer = undefined;
148
+ if (this.desired && !this.subscribed)
149
+ this.reconcile();
150
+ }, delay);
151
+ }
152
+ cancelRetry() {
153
+ if (this.retryTimer === undefined)
154
+ return;
155
+ clearTimeout(this.retryTimer);
156
+ this.retryTimer = undefined;
157
+ }
158
+ async read() {
159
+ const version = ++this.readVersion;
160
+ this.publish({ ...this.snapshot, error: undefined, loading: true });
161
+ try {
162
+ const result = await this.registry.app.readServerResource({ uri: this.uri });
163
+ if (!this.desired || !this.subscribed || version !== this.readVersion)
164
+ return;
165
+ this.publish({ result, loading: false });
166
+ }
167
+ catch (cause) {
168
+ if (!this.desired || !this.subscribed || version !== this.readVersion)
169
+ return;
170
+ this.publish({ ...this.snapshot, error: resourceError(cause), loading: false });
171
+ }
172
+ }
173
+ publish(snapshot) {
174
+ this.snapshot = snapshot;
175
+ this.listeners.forEach((listener) => listener());
176
+ }
177
+ }
178
+ function resourceEntry(app, uri) {
179
+ let registry = registries.get(app);
180
+ if (!registry) {
181
+ registry = new McpResourceRegistry(app);
182
+ registries.set(app, registry);
183
+ }
184
+ let entry = registry.entries.get(uri);
185
+ if (!entry) {
186
+ entry = new McpResourceEntry(registry, uri);
187
+ registry.entries.set(uri, entry);
188
+ }
189
+ return entry;
190
+ }
191
+ function resourceError(cause) {
192
+ return cause instanceof Error ? cause : new Error(String(cause));
193
+ }
194
+ function retryableResourceError(cause) {
195
+ if (!cause || typeof cause !== 'object' || !('code' in cause))
196
+ return true;
197
+ const code = cause.code;
198
+ return code !== -32700 && code !== -32600 && code !== -32601 && code !== -32602;
199
+ }
200
+ function emptySubscribe() {
201
+ return emptyRefresh;
202
+ }
203
+ function emptySnapshot() {
204
+ return EMPTY_SNAPSHOT;
205
+ }
206
+ function emptyRefresh() { }
@@ -0,0 +1,9 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import type { McpToolInput, McpToolOutput, TypedMcpTool } from './typed-schema.js';
3
+ export interface McpToolState<Input, Output> {
4
+ call: (input: Input) => Promise<Output>;
5
+ error: Error | undefined;
6
+ loading: boolean;
7
+ }
8
+ export declare function callMcpTool<Tool extends TypedMcpTool>(app: App, tool: Tool, input: McpToolInput<Tool>): Promise<McpToolOutput<Tool>>;
9
+ export declare function useMcpTool<Tool extends TypedMcpTool>(app: App | null | undefined, tool: Tool): McpToolState<McpToolInput<Tool>, McpToolOutput<Tool>>;
@@ -0,0 +1,73 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ export async function callMcpTool(app, tool, input) {
3
+ const parsedInput = tool.input.parse(input);
4
+ const result = await app.callServerTool({
5
+ name: tool.name,
6
+ arguments: asToolArguments(parsedInput),
7
+ });
8
+ if (result.isError)
9
+ throw toolCallError(tool.name, result);
10
+ return tool.output.parse(result.structuredContent);
11
+ }
12
+ export function useMcpTool(app, tool) {
13
+ const [status, setStatus] = useState({
14
+ app,
15
+ error: undefined,
16
+ pending: 0,
17
+ tool,
18
+ });
19
+ const mounted = useRef(true);
20
+ useEffect(() => {
21
+ mounted.current = true;
22
+ return () => {
23
+ mounted.current = false;
24
+ };
25
+ }, []);
26
+ const call = useCallback(async (input) => {
27
+ if (!app)
28
+ throw new Error(`Cannot call MCP tool ${tool.name}: the app is not connected`);
29
+ setStatus((current) => ({
30
+ app,
31
+ error: undefined,
32
+ pending: current.app === app && current.tool === tool ? current.pending + 1 : 1,
33
+ tool,
34
+ }));
35
+ try {
36
+ return await callMcpTool(app, tool, input);
37
+ }
38
+ catch (cause) {
39
+ const nextError = mcpToolError(cause);
40
+ if (mounted.current) {
41
+ setStatus((current) => current.app === app && current.tool === tool
42
+ ? { ...current, error: nextError }
43
+ : current);
44
+ }
45
+ throw nextError;
46
+ }
47
+ finally {
48
+ if (mounted.current) {
49
+ setStatus((current) => current.app === app && current.tool === tool
50
+ ? { ...current, pending: Math.max(0, current.pending - 1) }
51
+ : current);
52
+ }
53
+ }
54
+ }, [app, tool]);
55
+ const currentStatus = status.app === app && status.tool === tool ? status : undefined;
56
+ return { call, error: currentStatus?.error, loading: Boolean(currentStatus?.pending) };
57
+ }
58
+ function asToolArguments(input) {
59
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
60
+ throw new Error('MCP tool input must be an object');
61
+ }
62
+ return input;
63
+ }
64
+ function toolCallError(name, result) {
65
+ const detail = result.content
66
+ .filter((item) => Boolean(item.type === 'text' && item.text.trim()))
67
+ .map((item) => item.text.trim())
68
+ .join('\n');
69
+ return new Error(detail ? `MCP tool ${name} failed: ${detail}` : `MCP tool ${name} failed`);
70
+ }
71
+ function mcpToolError(cause) {
72
+ return cause instanceof Error ? cause : new Error(String(cause));
73
+ }
@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
6
6
  const MCP_APP_SOURCE_METADATA_PROPERTY = '__atmosMcpSource';
7
7
  const REACT_COMPONENT_FILE = /\.[jt]sx$/i;
8
8
  const NON_PRODUCTION_COMPONENT_FILE = /\.(?:stories|test|spec)\.[cm]?[jt]sx?$/i;
9
- export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '../dist', }) {
9
+ export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '../app-build', }) {
10
10
  return defineConfig({
11
11
  root,
12
12
  publicDir: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmos.build/ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The complete atmOS React component system and MCP App authoring helpers.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -64,6 +64,7 @@
64
64
  "@emoji-mart/data": "^1.2.1",
65
65
  "@lezer/highlight": "^1.2.3",
66
66
  "@modelcontextprotocol/ext-apps": "1.7.5",
67
+ "@modelcontextprotocol/sdk": "1.29.0",
67
68
  "@radix-ui/react-roving-focus": "^1.1.15",
68
69
  "@radix-ui/react-slot": "^1.3.0",
69
70
  "@tailwindcss/postcss": "^4.3.2",