@datalayer/core 1.2.10 → 1.2.12

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 (41) hide show
  1. package/README.md +2 -2
  2. package/lib/api/iam/authentication.js +6 -4
  3. package/lib/api/iam/healthz.js +2 -2
  4. package/lib/api/iam/oauth2.js +18 -12
  5. package/lib/api/iam/secrets.js +1 -2
  6. package/lib/api/mcp/observability.d.ts +9 -2
  7. package/lib/api/mcp/observability.js +40 -15
  8. package/lib/client/auth/strategies.js +1 -1
  9. package/lib/client/mixins/IAMMixin.js +2 -2
  10. package/lib/components/checkout/StripeCheckout.d.ts +1 -1
  11. package/lib/components/checkout/StripeCheckout.js +1 -1
  12. package/lib/components/display/VisuallyHidden.d.ts +1 -2
  13. package/lib/components/sharing/sandboxSharing.js +7 -1
  14. package/lib/config/Configuration.d.ts +4 -4
  15. package/lib/config/Configuration.js +7 -7
  16. package/lib/config/index.d.ts +1 -0
  17. package/lib/config/index.js +1 -0
  18. package/lib/config/planes.d.ts +45 -0
  19. package/lib/config/planes.js +53 -0
  20. package/lib/hooks/useBackdrop.d.ts +4 -5
  21. package/lib/hooks/useCache.d.ts +29 -19
  22. package/lib/hooks/useCache.js +164 -189
  23. package/lib/hooks/useMcp.js +1 -1
  24. package/lib/hooks/useNavigate.js +10 -0
  25. package/lib/hooks/useWindowSize.js +1 -1
  26. package/lib/models/IAMProvidersSpecs.d.ts +10 -5
  27. package/lib/models/IAMProvidersSpecs.js +0 -23
  28. package/lib/navigation/adapters/react-router.d.ts +1 -1
  29. package/lib/navigation/adapters/react-router.js +3 -0
  30. package/lib/state/sessionEnd.js +1 -1
  31. package/lib/state/substates/CoreState.js +3 -16
  32. package/lib/state/substates/IAMState.d.ts +3 -3
  33. package/lib/state/substates/NavigationState.d.ts +82 -0
  34. package/lib/state/substates/NavigationState.js +255 -0
  35. package/lib/state/substates/index.d.ts +1 -0
  36. package/lib/state/substates/index.js +1 -0
  37. package/lib/utils/Screencapture.d.ts +15 -0
  38. package/lib/utils/Screencapture.js +26 -23
  39. package/lib/views/mcp/McpHome.js +1 -1
  40. package/package.json +6 -4
  41. package/scripts/generate-mcp-types.py +15 -9
@@ -0,0 +1,82 @@
1
+ /** The signed-in shell: the platform, the business cases, or the admin. */
2
+ export type NavigationView = 'home' | 'agentify' | 'admin';
3
+ /** The shells, and where each one opens when it has not been visited. */
4
+ export declare const NAVIGATION_VIEW_HOMES: Record<NavigationView, string>;
5
+ /** Well-known view identifiers for {@link NavigationState.tabsByView}. */
6
+ export declare const LIBRARY_VIEW = "library";
7
+ export declare const AGENTS_VIEW = "agents";
8
+ export declare const SETTINGS_VIEW = "settings";
9
+ export declare const DATALAYER_VIEW = "datalayer";
10
+ export declare const CONTENTS_VIEW = "contents";
11
+ export declare const TUTOR_VIEW = "tutor";
12
+ export type NavigationState = {
13
+ /** The shell the reader last chose. */
14
+ view: NavigationView;
15
+ /** State which shell is being shown. */
16
+ setView: (view: NavigationView) => void;
17
+ /** Swap the platform and the business cases. */
18
+ toggleView: () => void;
19
+ /** The page each shell was last on. */
20
+ lastRouteByView: Partial<Record<NavigationView, string>>;
21
+ /**
22
+ * Remember a page as the current shell's, or as another's when told.
23
+ *
24
+ * Called by {@link useNavigate} for every navigation, which is what makes
25
+ * this the one place the answer comes from.
26
+ */
27
+ rememberRoute: (route: string, view?: NavigationView) => void;
28
+ /** Where a shell was left, or where it opens when it never was. */
29
+ routeForView: (view: NavigationView) => string;
30
+ /** Last selected tab, by view identifier. */
31
+ tabsByView: Record<string, string>;
32
+ /** Keep the tab a view was left on. */
33
+ setViewTab: (view: string, tab: string) => void;
34
+ /** The tab a view was left on, if any. */
35
+ getViewTab: (view: string) => string | undefined;
36
+ /**
37
+ * The route a signed-out reader asked for, to resume after signing in.
38
+ *
39
+ * Never persisted: a reload must land on the default route, and the OAuth
40
+ * round trip carries its target in `post_auth_redirect` instead.
41
+ */
42
+ requestedRoute?: string;
43
+ setRequestedRoute: (route: string) => void;
44
+ clearRequestedRoute: () => void;
45
+ /** The address the reader was on before the documentation. */
46
+ docsOrigin?: string;
47
+ setDocsOrigin: (origin: string) => void;
48
+ };
49
+ /**
50
+ * Whether a page is worth remembering as a shell's.
51
+ *
52
+ * Not the sign-in pages, the OAuth callbacks or the documentation: coming
53
+ * back to a shell should land on the work, not on the door the reader came
54
+ * through or on a page that belongs to no shell.
55
+ */
56
+ export declare function isRememberableRoute(route: string): boolean;
57
+ /**
58
+ * The shell a route belongs to, read off its first segment.
59
+ *
60
+ * The store used to trust whatever `view` said when a route was recorded,
61
+ * and `view` was only ever written by the header toggle. A reader who
62
+ * reached Home through a sidebar link while the toggle still said Agentify
63
+ * had their Home pages filed under Agentify — and the toggle, seeing
64
+ * "Agentify" already chosen, did nothing when they clicked it.
65
+ */
66
+ export declare function viewForRoute(route: string): NavigationView;
67
+ export declare const navigationStore: import("zustand").StoreApi<NavigationState>;
68
+ /**
69
+ * The hook, shaped like a `zustand` store created with `create`: callable
70
+ * with or without a selector, and carrying `getState`, `setState` and
71
+ * `subscribe` for the callers — a router guard, a test — that read it outside
72
+ * a render.
73
+ */
74
+ export interface UseNavigationStore {
75
+ (): NavigationState;
76
+ <T>(selector: (state: NavigationState) => T): T;
77
+ getState: () => NavigationState;
78
+ setState: typeof navigationStore.setState;
79
+ subscribe: typeof navigationStore.subscribe;
80
+ }
81
+ export declare const useNavigationStore: UseNavigationStore;
82
+ export default useNavigationStore;
@@ -0,0 +1,255 @@
1
+ /*
2
+ * Copyright (c) 2023-2025 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * Where the reader is, where they were, and where they left each shell.
7
+ *
8
+ * One store for navigation, in core, so every surface reads and writes the
9
+ * same thing. It answers five questions that used to be four stores in the
10
+ * landing and a handful of ad-hoc effects:
11
+ *
12
+ * - **the shell** — the platform Home, Agentify, or Admin;
13
+ * - **the page each shell was left on**, so switching shells returns the
14
+ * reader to their work rather than to a landing page;
15
+ * {@link useNavigate} records it on every navigation;
16
+ * - **the tab** last chosen in each tabbed view;
17
+ * - **the route asked for** before signing in, to resume afterwards;
18
+ * - **the address before the documentation**, for its way back.
19
+ *
20
+ * **Where each is kept.** The shell rides in a cookie, so it is sent with
21
+ * every request and a server render can read it. The shells' last pages and
22
+ * the tabs are in `localStorage`, which a cookie should not carry. The
23
+ * requested route and the documentation's origin are memory only, on
24
+ * purpose: a reload must forget the route asked for before signing in.
25
+ *
26
+ * Every read and write goes through the storage in a `try`: a browser that
27
+ * refuses its storage forgets, and never throws into a render.
28
+ *
29
+ * @module state/substates/NavigationState
30
+ */
31
+ import { useStore } from 'zustand';
32
+ import { createStore } from 'zustand/vanilla';
33
+ import { registerSessionState } from '../sessionEnd.js';
34
+ /** The shells, and where each one opens when it has not been visited. */
35
+ export const NAVIGATION_VIEW_HOMES = {
36
+ home: '/',
37
+ agentify: '/agentify',
38
+ admin: '/admin',
39
+ };
40
+ /** Well-known view identifiers for {@link NavigationState.tabsByView}. */
41
+ export const LIBRARY_VIEW = 'library';
42
+ export const AGENTS_VIEW = 'agents';
43
+ export const SETTINGS_VIEW = 'settings';
44
+ export const DATALAYER_VIEW = 'datalayer';
45
+ export const CONTENTS_VIEW = 'contents';
46
+ export const TUTOR_VIEW = 'tutor';
47
+ /** Where this store is kept, and what the landing kept it under before. */
48
+ const STORAGE_KEY = 'datalayer-navigation';
49
+ const SHELL_COOKIE = 'datalayer-signed-in-view';
50
+ const FORMER_TABS_KEY = 'datalayer_view_tabs';
51
+ /** Days a remembered shell lives in the cookie. */
52
+ const COOKIE_MAX_AGE_DAYS = 365;
53
+ const isView = (value) => value === 'home' || value === 'agentify' || value === 'admin';
54
+ function readCookie(name) {
55
+ if (typeof document === 'undefined') {
56
+ return undefined;
57
+ }
58
+ const match = document.cookie
59
+ .split('; ')
60
+ .find(row => row.startsWith(`${name}=`));
61
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
62
+ }
63
+ function writeCookie(name, value) {
64
+ if (typeof document === 'undefined') {
65
+ return;
66
+ }
67
+ const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
68
+ document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
69
+ }
70
+ function readLocal(name) {
71
+ try {
72
+ return globalThis.localStorage?.getItem(name) ?? null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * What the last visit left, from the two places it belongs in.
80
+ *
81
+ * The shell is taken from the cookie it has always used, so a reader who
82
+ * chose Agentify before this store existed still finds it. The tabs kept
83
+ * under the landing's former key are picked up once.
84
+ */
85
+ function restore() {
86
+ let persisted = {};
87
+ const stored = readLocal(STORAGE_KEY);
88
+ if (stored) {
89
+ try {
90
+ const parsed = JSON.parse(stored);
91
+ // The landing kept this under the same key through zustand's
92
+ // `persist`, which wraps what it writes in `{ state, version }`.
93
+ persisted = parsed?.state ?? parsed ?? {};
94
+ }
95
+ catch {
96
+ persisted = {};
97
+ }
98
+ }
99
+ else {
100
+ const former = readLocal(FORMER_TABS_KEY);
101
+ if (former) {
102
+ try {
103
+ const { state } = JSON.parse(former);
104
+ persisted = { tabsByView: state?.tabsByView ?? {} };
105
+ }
106
+ catch {
107
+ persisted = {};
108
+ }
109
+ }
110
+ }
111
+ const shell = readCookie(SHELL_COOKIE);
112
+ if (isView(shell)) {
113
+ persisted.view = shell;
114
+ }
115
+ return persisted;
116
+ }
117
+ function persist(state) {
118
+ const written = {
119
+ view: state.view,
120
+ lastRouteByView: state.lastRouteByView,
121
+ tabsByView: state.tabsByView,
122
+ };
123
+ try {
124
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(written));
125
+ }
126
+ catch {
127
+ /* the shells' pages are not remembered */
128
+ }
129
+ writeCookie(SHELL_COOKIE, state.view);
130
+ }
131
+ /**
132
+ * Whether a page is worth remembering as a shell's.
133
+ *
134
+ * Not the sign-in pages, the OAuth callbacks or the documentation: coming
135
+ * back to a shell should land on the work, not on the door the reader came
136
+ * through or on a page that belongs to no shell.
137
+ */
138
+ export function isRememberableRoute(route) {
139
+ if (!route.startsWith('/')) {
140
+ return false;
141
+ }
142
+ const path = route.split('?')[0];
143
+ return ![
144
+ '/signin',
145
+ '/signup',
146
+ '/login',
147
+ '/logout',
148
+ '/oauth2',
149
+ '/oauth',
150
+ '/docs',
151
+ '/join',
152
+ ].some(prefix => path === prefix || path.startsWith(`${prefix}/`));
153
+ }
154
+ /**
155
+ * The shell a route belongs to, read off its first segment.
156
+ *
157
+ * The store used to trust whatever `view` said when a route was recorded,
158
+ * and `view` was only ever written by the header toggle. A reader who
159
+ * reached Home through a sidebar link while the toggle still said Agentify
160
+ * had their Home pages filed under Agentify — and the toggle, seeing
161
+ * "Agentify" already chosen, did nothing when they clicked it.
162
+ */
163
+ export function viewForRoute(route) {
164
+ const path = route.split('?')[0];
165
+ if (path === '/agentify' || path.startsWith('/agentify/')) {
166
+ return 'agentify';
167
+ }
168
+ if (path === '/admin' || path.startsWith('/admin/')) {
169
+ return 'admin';
170
+ }
171
+ return 'home';
172
+ }
173
+ const initial = restore();
174
+ export const navigationStore = createStore((set, get) => ({
175
+ view: initial.view ?? 'home',
176
+ lastRouteByView: initial.lastRouteByView ?? {},
177
+ tabsByView: initial.tabsByView ?? {},
178
+ requestedRoute: undefined,
179
+ docsOrigin: undefined,
180
+ setView: view => {
181
+ set({ view });
182
+ persist(get());
183
+ },
184
+ toggleView: () => {
185
+ set(state => ({ view: state.view === 'home' ? 'agentify' : 'home' }));
186
+ persist(get());
187
+ },
188
+ rememberRoute: (route, view) => {
189
+ if (!isRememberableRoute(route)) {
190
+ return;
191
+ }
192
+ // The route says which shell it belongs to; the caller may only insist
193
+ // when it knows better. And being on a shell's page *is* being in that
194
+ // shell, so the view follows.
195
+ const shell = view ?? viewForRoute(route);
196
+ // Told which shell, the caller is filing a page away, not going there.
197
+ const follow = view === undefined;
198
+ const current = get();
199
+ if (current.lastRouteByView[shell] === route &&
200
+ (!follow || current.view === shell)) {
201
+ return;
202
+ }
203
+ set(state => ({
204
+ view: follow ? shell : state.view,
205
+ lastRouteByView: { ...state.lastRouteByView, [shell]: route },
206
+ }));
207
+ persist(get());
208
+ },
209
+ routeForView: view => {
210
+ // Only a page of that shell: an earlier version filed pages under the
211
+ // shell the toggle named rather than the one the route belonged to, so a
212
+ // reader can still have `/items` written down as Agentify's page. Going
213
+ // "to Agentify" must never land on a Home page.
214
+ const remembered = get().lastRouteByView[view];
215
+ return remembered && viewForRoute(remembered) === view
216
+ ? remembered
217
+ : NAVIGATION_VIEW_HOMES[view];
218
+ },
219
+ setViewTab: (view, tab) => {
220
+ if (get().tabsByView[view] === tab) {
221
+ return;
222
+ }
223
+ set(state => ({ tabsByView: { ...state.tabsByView, [view]: tab } }));
224
+ persist(get());
225
+ },
226
+ getViewTab: view => get().tabsByView[view],
227
+ setRequestedRoute: route => set({ requestedRoute: route }),
228
+ clearRequestedRoute: () => set({ requestedRoute: undefined }),
229
+ setDocsOrigin: origin => set({ docsOrigin: origin }),
230
+ }));
231
+ // The pages each shell was left on, the tabs, the route asked for and the
232
+ // way back from the documentation belong to the person who was signed in:
233
+ // forgotten when the session ends. The shell itself stays — it is the
234
+ // browser's choice of surface, read by the anonymous landing too.
235
+ registerSessionState({
236
+ forget: () => {
237
+ navigationStore.setState({
238
+ lastRouteByView: {},
239
+ tabsByView: {},
240
+ requestedRoute: undefined,
241
+ docsOrigin: undefined,
242
+ });
243
+ persist(navigationStore.getState());
244
+ },
245
+ localStorageKeys: [FORMER_TABS_KEY],
246
+ });
247
+ function useNavigationState(selector) {
248
+ return useStore(navigationStore, selector ?? ((state) => state));
249
+ }
250
+ export const useNavigationStore = Object.assign(useNavigationState, {
251
+ getState: navigationStore.getState,
252
+ setState: navigationStore.setState,
253
+ subscribe: navigationStore.subscribe,
254
+ });
255
+ export default useNavigationStore;
@@ -1,6 +1,7 @@
1
1
  export * from './CoreState';
2
2
  export * from './IAMState';
3
3
  export * from './LayoutState';
4
+ export * from './NavigationState';
4
5
  export * from './ProfileState';
5
6
  export * from './OrganizationState';
6
7
  export * from './SurveysState';
@@ -5,6 +5,7 @@
5
5
  export * from './CoreState.js';
6
6
  export * from './IAMState.js';
7
7
  export * from './LayoutState.js';
8
+ export * from './NavigationState.js';
8
9
  export * from './ProfileState.js';
9
10
  export * from './OrganizationState.js';
10
11
  export * from './SurveysState.js';
@@ -1 +1,16 @@
1
+ /**
2
+ * A picture of one rendered node, as a PNG data URL.
3
+ *
4
+ * This used `html2canvas`, which does not capture a node: it clones the
5
+ * **whole document** into a hidden iframe and paints all of it, then crops.
6
+ * On a rendered notebook that is every cell, every editor and every output,
7
+ * and the clone is synchronous — the page froze for ten seconds or more with
8
+ * the share dialog saying "preparing…", which reads as a hang, and on a
9
+ * longer notebook it was one. `html-to-image` serialises the node's own
10
+ * subtree into an SVG `foreignObject` and rasterises that: the work is
11
+ * proportional to the output being shared, and it yields to the page.
12
+ *
13
+ * Cross-origin pictures are fetched for the clone, as before, so a cell
14
+ * carrying an `<img>` from the web does not capture as a blank rectangle.
15
+ */
1
16
  export declare const takeHTMLNodeScreencapture: (node: HTMLDivElement) => Promise<string>;
@@ -2,29 +2,32 @@
2
2
  * Copyright (c) 2023-2025 Datalayer, Inc.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- import html2canvas from 'html2canvas';
5
+ import { toPng } from 'html-to-image';
6
+ /**
7
+ * A picture of one rendered node, as a PNG data URL.
8
+ *
9
+ * This used `html2canvas`, which does not capture a node: it clones the
10
+ * **whole document** into a hidden iframe and paints all of it, then crops.
11
+ * On a rendered notebook that is every cell, every editor and every output,
12
+ * and the clone is synchronous — the page froze for ten seconds or more with
13
+ * the share dialog saying "preparing…", which reads as a hang, and on a
14
+ * longer notebook it was one. `html-to-image` serialises the node's own
15
+ * subtree into an SVG `foreignObject` and rasterises that: the work is
16
+ * proportional to the output being shared, and it yields to the page.
17
+ *
18
+ * Cross-origin pictures are fetched for the clone, as before, so a cell
19
+ * carrying an `<img>` from the web does not capture as a blank rectangle.
20
+ */
6
21
  export const takeHTMLNodeScreencapture = async (node) => {
7
- const sc = html2canvas(node, {
8
- width: node.getBoundingClientRect().width,
9
- height: node.getBoundingClientRect().height,
10
- // Cross-origin pictures are otherwise left out and their space kept, so a
11
- // cell carrying an `<img>` from the web captures as a blank rectangle.
12
- useCORS: true,
13
- });
14
- return sc.then(canvas => {
15
- const croppedCanvas = document.createElement('canvas');
16
- const croppedCanvasContext = croppedCanvas.getContext('2d');
17
- const top = 0;
18
- const left = 0;
19
- const width = canvas.width;
20
- const height = canvas.height;
21
- croppedCanvas.width = width;
22
- croppedCanvas.height = height;
23
- croppedCanvasContext?.drawImage(canvas, left, top, width, height, left, top, width, height);
24
- // const type = 'image/jpeg';
25
- const type = 'image/png';
26
- const quality = 1;
27
- const outputshotData = croppedCanvas.toDataURL(type, quality);
28
- return outputshotData;
22
+ const { backgroundColor } = getComputedStyle(node);
23
+ return toPng(node, {
24
+ // Crisp on a dense display, still a modest file for a text output.
25
+ pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
26
+ // Outputs are usually transparent over the page's canvas: give the
27
+ // picture that canvas rather than a transparent one.
28
+ backgroundColor: backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)'
29
+ ? backgroundColor
30
+ : getComputedStyle(document.body).backgroundColor || '#ffffff',
31
+ cacheBust: true,
29
32
  });
30
33
  };
@@ -75,7 +75,7 @@ const Summary = ({ icon: Icon, title, children, action, }) => (_jsxs(Box, { sx:
75
75
  }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2 }, children: [_jsx(Icon, { size: 16 }), _jsx(Text, { sx: { fontSize: 1, fontWeight: 'semibold' }, children: title })] }), _jsx(Box, { sx: { fontSize: 0, color: 'fg.muted', display: 'grid', gap: 1 }, children: children }), action && (_jsx(Box, { children: _jsx(Link, { as: "button", sx: { fontSize: 0, cursor: 'pointer' }, onClick: action.onClick, children: action.label }) }))] }));
76
76
  export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitle = true, showSummaries = true, showSetup = true, onOpenDocs, }) => {
77
77
  const navigate = useNavigate();
78
- const endpoint = useCoreStore(state => state.configuration.jupyterMcpServerUrl);
78
+ const endpoint = useCoreStore(state => state.configuration.datalayerMcpServerUrl);
79
79
  // Whether there is a token, never what it is: this page shows an address
80
80
  // and a status, and a credential belongs on neither.
81
81
  const signedIn = Boolean(useIAMStore(state => state.token));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/core",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -111,8 +111,8 @@
111
111
  "dependencies": {
112
112
  "@datalayer/design": "^1.0.0",
113
113
  "@datalayer/icons-react": "^1.0.7",
114
- "@datalayer/jupyter-lexical": "^1.0.16",
115
- "@datalayer/jupyter-react": "^2.0.7",
114
+ "@datalayer/jupyter-lexical": "^1.0.24",
115
+ "@datalayer/jupyter-react": "^2.0.15",
116
116
  "@datalayer/primer-addons": "^1.0.15",
117
117
  "@datalayer/primer-rjsf": "^1.0.3",
118
118
  "@fluentui/react": "^8.125.3",
@@ -155,7 +155,7 @@
155
155
  "diff": "^8.0.2",
156
156
  "echarts": "^5.5.0",
157
157
  "echarts-for-react": "^3.0.2",
158
- "html2canvas": "^1.4.1",
158
+ "html-to-image": "^1.11.13",
159
159
  "jwt-decode": "^2.2.0",
160
160
  "localforage": "^1.10.0",
161
161
  "lucide-react": "^0.542.0",
@@ -200,6 +200,8 @@
200
200
  "@storybook/builder-vite": "^9.1.1",
201
201
  "@storybook/react-vite": "^9.1.1",
202
202
  "@tailwindcss/postcss": "^4.1.16",
203
+ "@testing-library/dom": "^10.4.1",
204
+ "@testing-library/react": "^16.3.3",
203
205
  "@types/node": "^20.11.0",
204
206
  "@types/react": "19.2.18",
205
207
  "@types/react-dom": "19.2.5",
@@ -6,13 +6,14 @@
6
6
  # Distributed under the terms of the Modified BSD License.
7
7
 
8
8
  """
9
- Generate deterministic TypeScript wire types from the Jupyter MCP Server's
10
- OpenAPI document, on the `generate-contents-types.py` pattern.
9
+ Generate TypeScript wire types from the Jupyter MCP Server's OpenAPI document.
10
+
11
+ The output is deterministic, on the `generate-contents-types.py` pattern.
11
12
 
12
13
  The gateway keeps no contract file: its FastAPI application *is* the
13
14
  contract, and the document is read from it — imported from the sibling
14
15
  checkout of the service when there is one, otherwise fetched from a running
15
- gateway. `DATALAYER_JUPYTER_MCP_OPENAPI` names either a file or an URL and
16
+ gateway. `DATALAYER_MCP_OPENAPI` names either a file or an URL and
16
17
  wins over both; the deployed gateway publishes its document without
17
18
  authentication at ``/api/mcp/v1/openapi.json``.
18
19
 
@@ -40,9 +41,9 @@ from typing import Any
40
41
  from urllib.request import Request, urlopen
41
42
 
42
43
  ROOT = Path(__file__).parents[1]
43
- DEFAULT_GATEWAY_CHECKOUT = ROOT.parents[2] / "k8s/services/jupyter-mcp-server"
44
+ DEFAULT_GATEWAY_CHECKOUT = ROOT.parents[2] / "k8s/services/mcp-server"
44
45
  DEFAULT_GATEWAY_OPENAPI_URL = "https://r1.datalayer.run/api/mcp/v1/openapi.json"
45
- GATEWAY_APP = "datalayer_jupyter_mcp_server.main:app"
46
+ GATEWAY_APP = "datalayer_mcp_server.main:app"
46
47
  OUTPUT = ROOT / "src/api/mcp/generated.ts"
47
48
  #: The licence header every source file in this repository carries. It is
48
49
  #: emitted here because `fix-license-header` adds it to whatever this writes,
@@ -106,13 +107,14 @@ def camel(name: str) -> str:
106
107
 
107
108
 
108
109
  def _read_from_checkout(checkout: Path) -> dict[str, Any] | None:
109
- """The document of the application in a sibling checkout, in a subprocess.
110
+ """
111
+ The document of the application in a sibling checkout, in a subprocess.
110
112
 
111
113
  A subprocess so the gateway's imports — its dependencies, its logging
112
114
  configuration — never leak into this one, and so an environment that
113
115
  cannot import it says so cleanly and the fetch takes over.
114
116
  """
115
- if not (checkout / "datalayer_jupyter_mcp_server").is_dir():
117
+ if not (checkout / "datalayer_mcp_server").is_dir():
116
118
  return None
117
119
  module, _, attribute = GATEWAY_APP.partition(":")
118
120
  code = (
@@ -137,13 +139,17 @@ def _read_from_checkout(checkout: Path) -> dict[str, Any] | None:
137
139
 
138
140
 
139
141
  def _read_from_url(url: str) -> dict[str, Any]:
140
- with urlopen(Request(url, headers={"Accept": "application/json"}), timeout=15) as response:
142
+ if not url.startswith(("http://", "https://")):
143
+ raise ValueError(f"Not an HTTP(S) URL: {url}")
144
+ with urlopen( # nosec B310 - the scheme is checked above
145
+ Request(url, headers={"Accept": "application/json"}), timeout=15
146
+ ) as response:
141
147
  return json.load(response)
142
148
 
143
149
 
144
150
  def read_openapi() -> dict[str, Any]:
145
151
  """The document, from the override, the sibling checkout or the deployed gateway."""
146
- override = os.environ.get("DATALAYER_JUPYTER_MCP_OPENAPI", "").strip()
152
+ override = os.environ.get("DATALAYER_MCP_OPENAPI", "").strip()
147
153
  if override.startswith(("http://", "https://")):
148
154
  return _read_from_url(override)
149
155
  if override: