@saga-a2ui/react-mcp-stack 0.0.0-stage → 0.7.6
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/LICENSE +202 -0
- package/README.md +16 -2
- package/package.json +1 -6
- package/src/action-setup.ts +53 -0
- package/src/application-call.ts +39 -0
- package/src/container-claim.ts +19 -0
- package/src/content-policy/index.ts +12 -0
- package/src/faithful-card/document-policy.ts +32 -0
- package/src/faithful-card/faithful-card.css +2 -0
- package/src/faithful-card/index.ts +35 -0
- package/src/faithful-card/start-faithful-card-app.tsx +288 -0
- package/src/host-connection.ts +39 -0
- package/src/index.ts +53 -0
- package/src/initial-render.ts +61 -0
- package/src/running-view.ts +221 -0
- package/src/server/index.ts +14 -0
- package/src/start-a2ui-view.tsx +220 -0
- package/src/start-failure.ts +41 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readCardResult,
|
|
3
|
+
type CardResultOutcome,
|
|
4
|
+
type FaithfulCardAppDefinition,
|
|
5
|
+
} from '@saga-a2ui/components/faithful-card';
|
|
6
|
+
import type { HostContext, ToolResult } from '@saga-a2ui/mcp/view';
|
|
7
|
+
import {
|
|
8
|
+
deliveredResultFromToolResult,
|
|
9
|
+
hostLinksFromApp,
|
|
10
|
+
} from '@saga-a2ui/mcp/view';
|
|
11
|
+
import {
|
|
12
|
+
createFaithfulCardView,
|
|
13
|
+
FaithfulCardProvider,
|
|
14
|
+
FaithfulCardSurface,
|
|
15
|
+
openFaithfulCardSurface,
|
|
16
|
+
type CardSurface,
|
|
17
|
+
type FaithfulCardView,
|
|
18
|
+
} from '@saga-a2ui/react/faithful-card';
|
|
19
|
+
import {
|
|
20
|
+
createElement,
|
|
21
|
+
Fragment,
|
|
22
|
+
useSyncExternalStore,
|
|
23
|
+
type ComponentType,
|
|
24
|
+
type ReactElement,
|
|
25
|
+
} from 'react';
|
|
26
|
+
import { claimContainer } from '../container-claim.js';
|
|
27
|
+
import { connectToHost, type HostConnection } from '../host-connection.js';
|
|
28
|
+
import { openStackRoot } from '../initial-render.js';
|
|
29
|
+
import {
|
|
30
|
+
runView,
|
|
31
|
+
viewFailedBeforeConnecting,
|
|
32
|
+
viewThatNeverStarted,
|
|
33
|
+
type RunningView,
|
|
34
|
+
} from '../running-view.js';
|
|
35
|
+
import { StartFailure, type ErrorPhase } from '../start-failure.js';
|
|
36
|
+
import {
|
|
37
|
+
contentPolicyFromDocument,
|
|
38
|
+
documentContentSecurityPolicies,
|
|
39
|
+
} from './document-policy.js';
|
|
40
|
+
|
|
41
|
+
/** What the application’s chrome is told about the card, and what to say beside it. */
|
|
42
|
+
export type CardViewState =
|
|
43
|
+
| { readonly kind: 'waiting'; readonly text: string }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'answer';
|
|
46
|
+
readonly heading: string | undefined;
|
|
47
|
+
/** Advances with every presented answer, including one whose text equals the last. */
|
|
48
|
+
readonly presentation: number;
|
|
49
|
+
}
|
|
50
|
+
| { readonly kind: 'problem'; readonly text: string };
|
|
51
|
+
|
|
52
|
+
/** What the chrome component receives: the card to place, and the state to word around it. */
|
|
53
|
+
export interface CardChromeProps {
|
|
54
|
+
/** The card. Place it exactly once; it is empty until the first answer. */
|
|
55
|
+
readonly card: ReactElement;
|
|
56
|
+
readonly state: CardViewState;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StartFaithfulCardOptions {
|
|
60
|
+
/** The element the view is rendered into. Must be attached to the document and free. */
|
|
61
|
+
readonly container: Element;
|
|
62
|
+
/** `own`: the view tells the host its size. `host`: the bridge reports it. */
|
|
63
|
+
readonly sizing: 'own' | 'host';
|
|
64
|
+
/** Optional. Work the view needs before it shows anything (for example fonts); `signal` aborts when the view ends. */
|
|
65
|
+
readonly readyWhen?: (signal: AbortSignal) => Promise<void>;
|
|
66
|
+
/** Optional. The negotiated host context and the host’s name, before the first answer and on change. */
|
|
67
|
+
readonly hostContext?: (
|
|
68
|
+
context: HostContext | undefined,
|
|
69
|
+
hostName: string | undefined,
|
|
70
|
+
) => void;
|
|
71
|
+
/** Optional. The application’s chrome around the card. By default the status text, then the card. */
|
|
72
|
+
readonly chrome?: ComponentType<CardChromeProps>;
|
|
73
|
+
/** Optional. Receives a throw from a callback after the view was ready. Defaults to `console.error`. */
|
|
74
|
+
readonly onError?: (error: unknown, phase: ErrorPhase) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DefaultChrome = (props: CardChromeProps): ReactElement => {
|
|
78
|
+
const { state } = props;
|
|
79
|
+
return createElement(
|
|
80
|
+
Fragment,
|
|
81
|
+
null,
|
|
82
|
+
state.kind === 'answer'
|
|
83
|
+
? state.heading === undefined
|
|
84
|
+
? null
|
|
85
|
+
: createElement('h2', null, state.heading)
|
|
86
|
+
: createElement('p', { role: 'status' }, state.text),
|
|
87
|
+
props.card,
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The state the chrome renders, readable by React and settable by the stack. */
|
|
92
|
+
function createStateStore(initial: CardViewState) {
|
|
93
|
+
let current = initial;
|
|
94
|
+
const listeners = new Set<() => void>();
|
|
95
|
+
return {
|
|
96
|
+
set(next: CardViewState): void {
|
|
97
|
+
current = next;
|
|
98
|
+
for (const listener of [...listeners]) listener();
|
|
99
|
+
},
|
|
100
|
+
get: (): CardViewState => current,
|
|
101
|
+
subscribe(listener: () => void): () => void {
|
|
102
|
+
listeners.add(listener);
|
|
103
|
+
return () => void listeners.delete(listener);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
type StateStore = ReturnType<typeof createStateStore>;
|
|
108
|
+
|
|
109
|
+
function CardApp(props: {
|
|
110
|
+
readonly view: FaithfulCardView | undefined;
|
|
111
|
+
readonly card: CardSurface | undefined;
|
|
112
|
+
readonly store: StateStore;
|
|
113
|
+
readonly Chrome: ComponentType<CardChromeProps>;
|
|
114
|
+
}): ReactElement {
|
|
115
|
+
const state = useSyncExternalStore(props.store.subscribe, props.store.get);
|
|
116
|
+
const { view, card, Chrome } = props;
|
|
117
|
+
const cardElement =
|
|
118
|
+
card === undefined
|
|
119
|
+
? createElement(Fragment)
|
|
120
|
+
: createElement(FaithfulCardSurface, { of: card });
|
|
121
|
+
const chrome = createElement(Chrome, { card: cardElement, state });
|
|
122
|
+
return view === undefined
|
|
123
|
+
? chrome
|
|
124
|
+
: createElement(FaithfulCardProvider, { view, children: chrome });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Starts a card application in `options.container` and returns it at once. The embedded policy is
|
|
129
|
+
* read from the container’s document, the host is connected with its handlers installed first,
|
|
130
|
+
* and each delivered result is read by the definition’s one `result` reader and shown in the card: a new
|
|
131
|
+
* presentation each time, even for equal text. Throws `StartError` (`container-missing`,
|
|
132
|
+
* `container-busy`) before connecting to anything. The first render happens before the host is
|
|
133
|
+
* connected: a `chrome` that throws in it does not throw out of `start`, it fails startup (`ready`
|
|
134
|
+
* rejects with `StartFailure` of kind `application`) after the stack took back what it made and
|
|
135
|
+
* freed the container. A throw in a later render goes to `onError` with phase
|
|
136
|
+
* `result`, the render a delivered result caused (default `console.error`); `ready` stays resolved.
|
|
137
|
+
*/
|
|
138
|
+
export function startFaithfulCardApp(
|
|
139
|
+
definition: FaithfulCardAppDefinition,
|
|
140
|
+
options: StartFaithfulCardOptions,
|
|
141
|
+
): RunningView {
|
|
142
|
+
return startFaithfulCardAppOn(
|
|
143
|
+
() => connectToHost(definition.identity, options.sizing),
|
|
144
|
+
definition,
|
|
145
|
+
options,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** {@link startFaithfulCardApp} over a connection the caller supplies: the seam the stack’s own tests use. */
|
|
150
|
+
export function startFaithfulCardAppOn(
|
|
151
|
+
connect: () => HostConnection,
|
|
152
|
+
definition: FaithfulCardAppDefinition,
|
|
153
|
+
options: StartFaithfulCardOptions,
|
|
154
|
+
): RunningView {
|
|
155
|
+
const claim = claimContainer(options.container);
|
|
156
|
+
let root: ReturnType<typeof openStackRoot> | undefined;
|
|
157
|
+
try {
|
|
158
|
+
const { texts } = definition;
|
|
159
|
+
const Chrome = options.chrome ?? DefaultChrome;
|
|
160
|
+
const store = createStateStore({ kind: 'waiting', text: texts.waiting });
|
|
161
|
+
const mounted = (root = openStackRoot(claim.container, options.onError));
|
|
162
|
+
const unmount = (): void => mounted.root.unmount();
|
|
163
|
+
/** The first render, now: the chrome is the application's, and a throw in it fails the start. */
|
|
164
|
+
const renderInitially = (view?: FaithfulCardView, card?: CardSurface) =>
|
|
165
|
+
mounted.renderInitially(
|
|
166
|
+
createElement(CardApp, { view, card, store, Chrome }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const policy = contentPolicyFromDocument(claim.container.ownerDocument);
|
|
170
|
+
if (!policy.ok) {
|
|
171
|
+
store.set({ kind: 'problem', text: texts.policyFailure });
|
|
172
|
+
const failed = renderInitially();
|
|
173
|
+
if (failed !== undefined) {
|
|
174
|
+
// The chrome threw while showing why the start failed: that is the failure to report.
|
|
175
|
+
unmount();
|
|
176
|
+
claim.release();
|
|
177
|
+
return viewThatNeverStarted(failed.cause);
|
|
178
|
+
}
|
|
179
|
+
return viewFailedBeforeConnecting({
|
|
180
|
+
failure: new StartFailure('policy', policy.failure),
|
|
181
|
+
unmount,
|
|
182
|
+
releaseContainer: claim.release,
|
|
183
|
+
...(options.onError === undefined ? {} : { onError: options.onError }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const connection = connect();
|
|
188
|
+
const lifetime = new AbortController();
|
|
189
|
+
const view = createFaithfulCardView({
|
|
190
|
+
...definition.reader,
|
|
191
|
+
policy: policy.policy,
|
|
192
|
+
contentSecurityPolicies: documentContentSecurityPolicies(
|
|
193
|
+
claim.container.ownerDocument,
|
|
194
|
+
),
|
|
195
|
+
hostLinks: hostLinksFromApp(connection.app, lifetime.signal),
|
|
196
|
+
});
|
|
197
|
+
const card = openFaithfulCardSurface();
|
|
198
|
+
const failed = renderInitially(view, card);
|
|
199
|
+
if (failed !== undefined) {
|
|
200
|
+
// The chrome threw on its first render. Nothing connected: take back what this start made.
|
|
201
|
+
lifetime.abort();
|
|
202
|
+
view.consent.retire();
|
|
203
|
+
card.dispose();
|
|
204
|
+
unmount();
|
|
205
|
+
claim.release();
|
|
206
|
+
return viewThatNeverStarted(failed.cause);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const report =
|
|
210
|
+
options.onError ?? ((error: unknown) => console.error(error));
|
|
211
|
+
let presentation = 0;
|
|
212
|
+
const problem = (text: string): void => {
|
|
213
|
+
card.clear();
|
|
214
|
+
store.set({ kind: 'problem', text });
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* The definition's one reader decides what the whole result means. A throw from the reader or
|
|
218
|
+
* from mapping the delivery's result is reported (phase `result`) and rejects the result.
|
|
219
|
+
*/
|
|
220
|
+
const read = (result: ToolResult): CardResultOutcome =>
|
|
221
|
+
readCardResult(
|
|
222
|
+
definition,
|
|
223
|
+
() => deliveredResultFromToolResult(result),
|
|
224
|
+
(error) => {
|
|
225
|
+
try {
|
|
226
|
+
report(error, 'result');
|
|
227
|
+
} catch {
|
|
228
|
+
// A reporter that throws must not stop the answer from clearing.
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return runView({
|
|
234
|
+
connection,
|
|
235
|
+
lifetime,
|
|
236
|
+
releaseContainer: claim.release,
|
|
237
|
+
sizing: options.sizing,
|
|
238
|
+
...(options.readyWhen === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { readyWhen: options.readyWhen }),
|
|
241
|
+
...(options.hostContext === undefined
|
|
242
|
+
? {}
|
|
243
|
+
: { hostContext: options.hostContext }),
|
|
244
|
+
// Every result is a new selection: `present` ends the earlier presentation, image consent
|
|
245
|
+
// included, even when the Markdown equals the previous one.
|
|
246
|
+
onToolResult(result) {
|
|
247
|
+
const outcome = read(result);
|
|
248
|
+
if (outcome.kind === 'rejected') {
|
|
249
|
+
// The application's wording for this rejection, else the definition's.
|
|
250
|
+
problem(outcome.text ?? texts.problem);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!card.present(outcome.content).ok) {
|
|
254
|
+
problem(texts.problem);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
presentation += 1;
|
|
258
|
+
store.set({
|
|
259
|
+
kind: 'answer',
|
|
260
|
+
heading: outcome.content.heading,
|
|
261
|
+
presentation,
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
...(options.onError === undefined ? {} : { onError: options.onError }),
|
|
265
|
+
retire() {
|
|
266
|
+
view.consent.retire();
|
|
267
|
+
card.dispose();
|
|
268
|
+
},
|
|
269
|
+
unmount,
|
|
270
|
+
showFailure(failure) {
|
|
271
|
+
const text = {
|
|
272
|
+
policy: texts.policyFailure,
|
|
273
|
+
connect: texts.connectFailure,
|
|
274
|
+
ready: texts.readyFailure,
|
|
275
|
+
application: texts.problem,
|
|
276
|
+
disposed: texts.problem,
|
|
277
|
+
}[failure.kind];
|
|
278
|
+
store.set({ kind: 'problem', text });
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Nothing connected: take back what this start rendered and free the container.
|
|
284
|
+
root?.root.unmount();
|
|
285
|
+
claim.release();
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two halves of a connection to the MCP Apps host: the bridge and the transport it will connect
|
|
5
|
+
* over. Making them is the stack’s job so that no application imports the bridge package; a start
|
|
6
|
+
* takes the pair as a value so a test can supply a bridge joined to an in-memory host.
|
|
7
|
+
*/
|
|
8
|
+
export interface HostConnection {
|
|
9
|
+
readonly app: App;
|
|
10
|
+
readonly transport: NonNullable<Parameters<App['connect']>[0]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What a server tool call answers: the reply type of the bridge’s own `callServerTool`. */
|
|
14
|
+
export type ServerReply = Awaited<ReturnType<App['callServerTool']>>;
|
|
15
|
+
|
|
16
|
+
export interface HostIdentity {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly version: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The connection a view uses in a host’s iframe: a bridge named for the application and the
|
|
23
|
+
* `postMessage` transport to the embedding window. With `own` sizing the view tells the host its
|
|
24
|
+
* size itself (`startOwnSizing`), so the bridge’s automatic resizing is turned off and sizing has
|
|
25
|
+
* exactly one owner.
|
|
26
|
+
*/
|
|
27
|
+
export function connectToHost(
|
|
28
|
+
identity: HostIdentity,
|
|
29
|
+
sizing: 'own' | 'host',
|
|
30
|
+
): HostConnection {
|
|
31
|
+
return {
|
|
32
|
+
app: new App(
|
|
33
|
+
{ name: identity.name, version: identity.version },
|
|
34
|
+
{},
|
|
35
|
+
sizing === 'own' ? { autoResize: false } : undefined,
|
|
36
|
+
),
|
|
37
|
+
transport: new PostMessageTransport(window.parent, window.parent),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The React and MCP Apps stack, main entry: an application's own catalog and nothing else. It
|
|
2
|
+
// imports nothing from `@saga-a2ui/components`, so an own-catalog application's emitted browser
|
|
3
|
+
// bundle holds neither the FaithfulCard nor a Markdown parser. (Its installed tree does include
|
|
4
|
+
// them, as declared dependencies of the stack; the card application's entry is `/faithful-card`.)
|
|
5
|
+
//
|
|
6
|
+
// An application that installs this stack and React writes its view, its catalog and its actions
|
|
7
|
+
// against the names below and declares no other SDK or upstream package for them. The list is
|
|
8
|
+
// what the qualifying consumers use, no more.
|
|
9
|
+
export {
|
|
10
|
+
startA2uiView,
|
|
11
|
+
type RunningA2uiView,
|
|
12
|
+
type StartA2uiViewOptions,
|
|
13
|
+
type ViewHost,
|
|
14
|
+
} from './start-a2ui-view.js';
|
|
15
|
+
// What a start can fail with and the phase an `onError` callback is told; the card entry shares them.
|
|
16
|
+
export {
|
|
17
|
+
StartError,
|
|
18
|
+
StartFailure,
|
|
19
|
+
type ErrorPhase,
|
|
20
|
+
type StartFailureKind,
|
|
21
|
+
type StartMisuse,
|
|
22
|
+
} from './start-failure.js';
|
|
23
|
+
export type { RunningView } from './running-view.js';
|
|
24
|
+
// The shapes of the two notifications an application's handlers receive.
|
|
25
|
+
export type { ToolInput, ToolResult } from '@saga-a2ui/mcp/view';
|
|
26
|
+
// How the reader meets the host (device or desktop), for an application that maps its own tokens.
|
|
27
|
+
export { readingProfile, type ReadingProfile } from '@saga-a2ui/mcp/view';
|
|
28
|
+
// The authoring surface of an own-catalog application: the layout and its schemas.
|
|
29
|
+
export { defineSurface } from '@saga-a2ui/core/protocols/a2ui/v0_9';
|
|
30
|
+
export type { SurfaceLayout } from '@saga-a2ui/core/protocols/a2ui/v0_9';
|
|
31
|
+
export { z } from '@saga-a2ui/core/protocols/a2ui/zod3';
|
|
32
|
+
// The catalog an application builds: its components over upstream's component contracts, kept in
|
|
33
|
+
// the renderer package's own dependency context.
|
|
34
|
+
export {
|
|
35
|
+
Catalog,
|
|
36
|
+
ColumnApi,
|
|
37
|
+
CommonSchemas,
|
|
38
|
+
createComponentImplementation,
|
|
39
|
+
ListApi,
|
|
40
|
+
RowApi,
|
|
41
|
+
type ReactCatalogComponent,
|
|
42
|
+
} from '@saga-a2ui/react/a2ui';
|
|
43
|
+
// The actions an application attaches to the controls it renders.
|
|
44
|
+
export {
|
|
45
|
+
ActionButton,
|
|
46
|
+
ActionButtonEntry,
|
|
47
|
+
createReactActions,
|
|
48
|
+
type ActionButtonActivation,
|
|
49
|
+
type ReactActions,
|
|
50
|
+
type ReactActionsOptions,
|
|
51
|
+
} from '@saga-a2ui/react/actions';
|
|
52
|
+
export { Action, type ActionBinding } from '@saga-a2ui/core/actions';
|
|
53
|
+
export type { BoundSurface } from '@saga-a2ui/react/a2ui';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
3
|
+
import { flushSync } from 'react-dom';
|
|
4
|
+
import type { ErrorPhase } from './start-failure.js';
|
|
5
|
+
|
|
6
|
+
// A view's first render is part of starting it: the application's chrome or catalog components
|
|
7
|
+
// run in it, and a throw there is an application callback that threw during startup. React reports
|
|
8
|
+
// such a throw asynchronously (to `window` by default) and unmounts the tree, so left alone the
|
|
9
|
+
// view would be "ready" with nothing on screen. This renders the first tree synchronously and
|
|
10
|
+
// hands the throw back, so the caller can end the start as `StartFailure('application')` after
|
|
11
|
+
// its own cleanup.
|
|
12
|
+
//
|
|
13
|
+
// A throw in a later render (the chrome or a component rendering a result) is an application
|
|
14
|
+
// callback that threw after the view was ready: it goes to the view's `onError` with phase
|
|
15
|
+
// `result`, the render that the delivered result caused. React still unmounts the tree it could
|
|
16
|
+
// not render, and nothing the application already changed is rolled back.
|
|
17
|
+
|
|
18
|
+
export interface StackRoot {
|
|
19
|
+
readonly root: Root;
|
|
20
|
+
/**
|
|
21
|
+
* Renders `tree` now. Returns the throw that React reported while doing so, if any; the root
|
|
22
|
+
* is then empty and the caller owns the cleanup.
|
|
23
|
+
*/
|
|
24
|
+
renderInitially(tree: ReactNode): { readonly cause: unknown } | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function openStackRoot(
|
|
28
|
+
container: Element,
|
|
29
|
+
onError: ((error: unknown, phase: ErrorPhase) => void) | undefined,
|
|
30
|
+
): StackRoot {
|
|
31
|
+
let initial: { readonly cause: unknown } | undefined;
|
|
32
|
+
let rendering = false;
|
|
33
|
+
const root = createRoot(container, {
|
|
34
|
+
onUncaughtError(error) {
|
|
35
|
+
if (rendering) {
|
|
36
|
+
initial ??= { cause: error };
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
(onError ?? ((reported: unknown) => console.error(reported)))(
|
|
41
|
+
error,
|
|
42
|
+
'result',
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
// A reporter that throws must not break React's own cleanup of the failed tree.
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
root,
|
|
51
|
+
renderInitially(tree) {
|
|
52
|
+
rendering = true;
|
|
53
|
+
try {
|
|
54
|
+
flushSync(() => root.render(tree));
|
|
55
|
+
} finally {
|
|
56
|
+
rendering = false;
|
|
57
|
+
}
|
|
58
|
+
return initial;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runToolResultView,
|
|
3
|
+
startOwnSizing,
|
|
4
|
+
ViewStartFailure,
|
|
5
|
+
type HostContext,
|
|
6
|
+
type ToolInput,
|
|
7
|
+
type ToolResult,
|
|
8
|
+
} from '@saga-a2ui/mcp/view';
|
|
9
|
+
import { applicationFailureOf } from './application-call.js';
|
|
10
|
+
import type { HostConnection } from './host-connection.js';
|
|
11
|
+
import { StartFailure, type ErrorPhase } from './start-failure.js';
|
|
12
|
+
|
|
13
|
+
// What both entries of the stack share once they have something to show: one view in one container
|
|
14
|
+
// whose lifetime is `runToolResultView`'s. It adds what is the stack’s alone: the typed failure,
|
|
15
|
+
// own sizing that starts only once the host can be told, the claim on the container, and when a
|
|
16
|
+
// view that has ended leaves the screen. It decides nothing about what is shown.
|
|
17
|
+
|
|
18
|
+
/** The handle both entries return, synchronously. */
|
|
19
|
+
export interface RunningView {
|
|
20
|
+
/**
|
|
21
|
+
* Settles when the embedded policy was valid, the host connected with the handlers installed
|
|
22
|
+
* first, the latest host context was applied and the application's readiness work finished.
|
|
23
|
+
* Rejects with a {@link StartFailure} otherwise. Ignoring it never causes an unhandled rejection.
|
|
24
|
+
*/
|
|
25
|
+
readonly ready: Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Ends the view and removes what it showed, whatever state it is in: ready, still starting, or
|
|
28
|
+
* failed (a failed view keeps its failure text on screen until it is disposed). Idempotent;
|
|
29
|
+
* settles after cleanup and never rejects. Afterwards the container may be started again.
|
|
30
|
+
*/
|
|
31
|
+
dispose(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunViewOptions {
|
|
35
|
+
readonly connection: HostConnection;
|
|
36
|
+
/** Aborts when the view ends. Anything the view started for the host (links, sizing) listens to it. */
|
|
37
|
+
readonly lifetime: AbortController;
|
|
38
|
+
/** Frees the container; called once, after `unmount`. */
|
|
39
|
+
readonly releaseContainer: () => void;
|
|
40
|
+
readonly sizing: 'own' | 'host';
|
|
41
|
+
readonly readyWhen?: (signal: AbortSignal) => Promise<void>;
|
|
42
|
+
readonly hostContext?: (
|
|
43
|
+
context: HostContext | undefined,
|
|
44
|
+
hostName: string | undefined,
|
|
45
|
+
) => void;
|
|
46
|
+
/** Called for each delivered result once the view is ready (the held one at that moment). */
|
|
47
|
+
readonly onToolResult: (result: ToolResult) => void;
|
|
48
|
+
/** Called for the tool call that is starting, once the view is ready and before the held result. */
|
|
49
|
+
readonly onToolInput?: (input: ToolInput) => void;
|
|
50
|
+
readonly onError?: (error: unknown, phase: ErrorPhase) => void;
|
|
51
|
+
/** The first step of ending, once, before the connection ends: retire consent and the surface. */
|
|
52
|
+
readonly retire: () => void;
|
|
53
|
+
/** Removes everything the view rendered. Called once. */
|
|
54
|
+
readonly unmount: () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Shows why the view did not start. Returns true when the failure is now on screen, which keeps
|
|
57
|
+
* the view mounted until it is disposed. Not asked for `disposed`, which shows nothing.
|
|
58
|
+
*/
|
|
59
|
+
readonly showFailure?: (failure: StartFailure) => boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function startFailureOf(failure: unknown): StartFailure {
|
|
63
|
+
if (failure instanceof ViewStartFailure) {
|
|
64
|
+
// Readiness work the stack itself holds for an application callback (the action setup).
|
|
65
|
+
const own = applicationFailureOf(failure.cause);
|
|
66
|
+
return own === undefined
|
|
67
|
+
? new StartFailure(failure.kind, failure.cause)
|
|
68
|
+
: new StartFailure('application', own.cause);
|
|
69
|
+
}
|
|
70
|
+
return new StartFailure('application', failure);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function runView(options: RunViewOptions): RunningView {
|
|
74
|
+
const { app, transport } = options.connection;
|
|
75
|
+
const report = options.onError ?? ((error: unknown) => console.error(error));
|
|
76
|
+
let onScreen = true;
|
|
77
|
+
let failureShown = false;
|
|
78
|
+
let sizingStarted = false;
|
|
79
|
+
|
|
80
|
+
const reportError = (error: unknown, phase: ErrorPhase): void => {
|
|
81
|
+
try {
|
|
82
|
+
report(error, phase);
|
|
83
|
+
} catch {
|
|
84
|
+
// A reporter that throws must not stop cleanup.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The view is gone from the screen: unmount, then free the container, whatever unmount did. */
|
|
89
|
+
const leave = (): void => {
|
|
90
|
+
if (!onScreen) return;
|
|
91
|
+
onScreen = false;
|
|
92
|
+
try {
|
|
93
|
+
options.unmount();
|
|
94
|
+
} finally {
|
|
95
|
+
options.releaseContainer();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const running = runToolResultView({
|
|
100
|
+
app,
|
|
101
|
+
transport,
|
|
102
|
+
...(options.readyWhen === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { readyWhen: options.readyWhen }),
|
|
105
|
+
onToolResult: options.onToolResult,
|
|
106
|
+
...(options.onToolInput === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { onToolInput: options.onToolInput }),
|
|
109
|
+
onHostContext(context, hostName) {
|
|
110
|
+
// Told only once the host can hear it: sizing before the handshake would be dropped, and the
|
|
111
|
+
// size it measured would then count as sent.
|
|
112
|
+
if (options.sizing === 'own' && !sizingStarted) {
|
|
113
|
+
sizingStarted = true;
|
|
114
|
+
startOwnSizing(app, options.lifetime.signal);
|
|
115
|
+
}
|
|
116
|
+
options.hostContext?.(context, hostName);
|
|
117
|
+
},
|
|
118
|
+
onTeardown(startupFailure) {
|
|
119
|
+
options.lifetime.abort();
|
|
120
|
+
const failures: unknown[] = [];
|
|
121
|
+
try {
|
|
122
|
+
options.retire();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
failures.push(error);
|
|
125
|
+
}
|
|
126
|
+
// Whatever ended the view, nothing it showed may outlast its connection. Only a start that
|
|
127
|
+
// failed keeps something on screen: the failure, which is known only after the connection
|
|
128
|
+
// has ended (see below).
|
|
129
|
+
if (startupFailure === undefined) {
|
|
130
|
+
try {
|
|
131
|
+
leave();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
reportError(error, 'teardown');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
queueMicrotask(() => {
|
|
137
|
+
void running.dispose().then(() => {
|
|
138
|
+
if (!failureShown) {
|
|
139
|
+
try {
|
|
140
|
+
leave();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
reportError(error, 'teardown');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
// Every step ran; the runner reports the first thing that threw and still ends the connection.
|
|
148
|
+
if (failures.length > 0) throw failures[0];
|
|
149
|
+
},
|
|
150
|
+
onFailure(failure) {
|
|
151
|
+
const startFailure = startFailureOf(failure);
|
|
152
|
+
if (startFailure.kind !== 'disposed')
|
|
153
|
+
failureShown = options.showFailure?.(startFailure) ?? false;
|
|
154
|
+
},
|
|
155
|
+
onError: options.onError ?? ((error, phase) => reportError(error, phase)),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const readiness = running.ready.then(
|
|
159
|
+
() => undefined,
|
|
160
|
+
(failure: unknown) => {
|
|
161
|
+
throw startFailureOf(failure);
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
readiness.catch(() => undefined);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ready: readiness,
|
|
168
|
+
async dispose() {
|
|
169
|
+
await running.dispose();
|
|
170
|
+
try {
|
|
171
|
+
leave();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
reportError(error, 'teardown');
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The view of a start that failed before it connected to anything (an invalid embedded policy):
|
|
181
|
+
* its failure is already on screen, `ready` rejects with it, and disposing it removes it.
|
|
182
|
+
*/
|
|
183
|
+
export function viewFailedBeforeConnecting(options: {
|
|
184
|
+
readonly failure: StartFailure;
|
|
185
|
+
readonly unmount: () => void;
|
|
186
|
+
readonly releaseContainer: () => void;
|
|
187
|
+
readonly onError?: (error: unknown, phase: ErrorPhase) => void;
|
|
188
|
+
}): RunningView {
|
|
189
|
+
let onScreen = true;
|
|
190
|
+
const ready = Promise.reject<void>(options.failure);
|
|
191
|
+
ready.catch(() => undefined);
|
|
192
|
+
return {
|
|
193
|
+
ready,
|
|
194
|
+
async dispose() {
|
|
195
|
+
if (!onScreen) return;
|
|
196
|
+
onScreen = false;
|
|
197
|
+
try {
|
|
198
|
+
options.unmount();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
try {
|
|
201
|
+
(options.onError ?? ((e) => console.error(e)))(error, 'teardown');
|
|
202
|
+
} catch {
|
|
203
|
+
// Nothing more to do for a reporter that throws.
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
options.releaseContainer();
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The view of a start whose application callback threw before anything ran: the stack already took
|
|
214
|
+
* back what it had made and freed the container, so `ready` rejects with the failure and there is
|
|
215
|
+
* nothing left to dispose.
|
|
216
|
+
*/
|
|
217
|
+
export function viewThatNeverStarted(cause: unknown): RunningView {
|
|
218
|
+
const ready = Promise.reject<void>(new StartFailure('application', cause));
|
|
219
|
+
ready.catch(() => undefined);
|
|
220
|
+
return { ready, dispose: () => Promise.resolve() };
|
|
221
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The server entry of the stack: what an application's MCP server composes to serve a card or an
|
|
2
|
+
// own-catalog view, from the same package it installs for the view. It re-exports the delivery
|
|
3
|
+
// owner's named operations and nothing from a renderer, `@saga-a2ui/components` or the browser
|
|
4
|
+
// entries, so a server never loads a renderer and a browser bundle never loads a server module.
|
|
5
|
+
// The operations, their failures and their lifetimes are specified in `@saga-a2ui/mcp/server`.
|
|
6
|
+
export {
|
|
7
|
+
attachRequestLifetime,
|
|
8
|
+
createServerBudgets,
|
|
9
|
+
operationViewFromHtml,
|
|
10
|
+
registerPublicOperation,
|
|
11
|
+
viewUriForHtml,
|
|
12
|
+
type OperationView,
|
|
13
|
+
type RequestLifetime,
|
|
14
|
+
} from '@saga-a2ui/mcp/server';
|