diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ea1eeea8a10..c213134b0e1 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8412,3 +8412,72 @@ export function FlowiseIcon(props: SVGProps) { ) } + +export function JupyterIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index f5f3a71a108..38effba830e 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -115,6 +115,7 @@ import { JinaAIIcon, JiraIcon, JiraServiceManagementIcon, + JupyterIcon, KalshiIcon, KetchIcon, LangsmithIcon, @@ -364,6 +365,7 @@ export const blockTypeToIconMap: Record = { jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, + jupyter: JupyterIcon, kalshi: KalshiIcon, kalshi_v2: KalshiIcon, ketch: KetchIcon, diff --git a/apps/docs/content/docs/en/integrations/jupyter.mdx b/apps/docs/content/docs/en/integrations/jupyter.mdx new file mode 100644 index 00000000000..81c333a73f2 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/jupyter.mdx @@ -0,0 +1,383 @@ +--- +title: Jupyter +description: Manage files, notebooks, kernels, and sessions on a Jupyter server +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels. + + + +## Actions + +### `jupyter_list_contents` + +List files, notebooks, and subdirectories at a path on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | No | Directory path to list, relative to the server root. Leave blank for root. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Directory entries at the requested path | +| ↳ `name` | string | Entry name | +| ↳ `path` | string | Entry path relative to server root | +| ↳ `type` | string | directory, file, or notebook | +| ↳ `writable` | boolean | Whether the entry is writable | +| ↳ `created` | string | Creation timestamp | +| ↳ `lastModified` | string | Last modified timestamp | +| ↳ `size` | number | Size in bytes | +| ↳ `mimetype` | string | MIME type \(files only\) | +| ↳ `format` | string | json, text, or base64 | +| `path` | string | The listed directory path | + +### `jupyter_get_content` + +Read a file or notebook from a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path of the file or notebook to read, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | File or notebook name | +| `path` | string | Path relative to the server root | +| `mimetype` | string | MIME type of the content | +| `text` | string | Text content, for text files and notebooks \(JSON-stringified\) | +| `file` | file | Binary content stored as a file, for base64-format content | + +### `jupyter_create_file` + +Create a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path to create, relative to the server root | +| `type` | string | Yes | Type of entry to create: file, notebook, or directory | +| `content` | string | No | Content to write. For a file, plain text. For a notebook, a JSON-stringified nbformat document \(defaults to an empty notebook\). Ignored for directories. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Created entry name | +| `path` | string | Created entry path | +| `type` | string | directory, file, or notebook | +| `createdAt` | string | Creation timestamp | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_upload_file` + +Upload a file to a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `directory` | string | No | Destination directory, relative to the server root. Leave blank to upload to the root directory. | +| `file` | file | No | The file to upload \(UserFile object\) | +| `fileContent` | string | No | Legacy: base64 encoded file content | +| `fileName` | string | No | Optional filename override | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Uploaded file name | +| `path` | string | Uploaded file path | +| `size` | number | File size in bytes | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_rename_content` + +Rename or move a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Current path of the entry, relative to the server root | +| `newPath` | string | Yes | New path for the entry, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | New entry name | +| `path` | string | New entry path | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_delete_content` + +Delete a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path of the entry to delete, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the entry was deleted | +| `path` | string | Deleted entry path | + +### `jupyter_copy_content` + +Duplicate a file or notebook into a directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Destination directory path, relative to the server root | +| `copyFromPath` | string | Yes | Path of the file or notebook to copy, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the copied entry | +| `path` | string | Path of the copied entry | +| `createdAt` | string | Creation timestamp | + +### `jupyter_list_kernels` + +List running kernels on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `kernels` | array | Running kernels | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_start_kernel` + +Start a new kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelName` | string | No | Kernel spec name to start \(e.g. python3\). Defaults to the server default. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Kernel ID | +| `name` | string | Kernel spec name | +| `lastActivity` | string | Last activity timestamp | +| `executionState` | string | Kernel execution state | +| `connections` | number | Active connection count | + +### `jupyter_stop_kernel` + +Shut down a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to shut down | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the kernel was shut down | +| `kernelId` | string | Shut down kernel ID | + +### `jupyter_restart_kernel` + +Restart a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to restart | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Kernel ID | +| `name` | string | Kernel spec name | +| `lastActivity` | string | Last activity timestamp | +| `executionState` | string | Kernel execution state | +| `connections` | number | Active connection count | + +### `jupyter_interrupt_kernel` + +Interrupt a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to interrupt | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the interrupt was sent | +| `kernelId` | string | Interrupted kernel ID | + +### `jupyter_list_kernelspecs` + +List available kernel specs (languages/runtimes) on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `defaultKernelName` | string | Default kernel spec name | +| `kernelspecs` | array | Available kernel specs | +| ↳ `name` | string | Kernel spec name | +| ↳ `displayName` | string | Human-readable display name | +| ↳ `language` | string | Kernel language | +| ↳ `argv` | array | Launch command arguments | +| ↳ `interruptMode` | string | Interrupt mode | + +### `jupyter_list_sessions` + +List active sessions (notebook-to-kernel bindings) on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessions` | array | Active sessions | +| ↳ `id` | string | Session ID | +| ↳ `path` | string | Notebook path bound to this session | +| ↳ `name` | string | Session name | +| ↳ `type` | string | Session type | +| ↳ `kernel` | object | Kernel bound to this session | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_create_session` + +Create a session that binds a notebook path to a (new or existing) kernel + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Notebook path to bind the session to, relative to the server root | +| `kernelName` | string | No | Kernel spec name to start for this session \(e.g. python3\) | +| `name` | string | No | Optional session name | +| `type` | string | No | Session type, defaults to 'notebook' | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Session ID | +| `path` | string | Notebook path bound to this session | +| `name` | string | Session name | +| `type` | string | Session type | +| `kernel` | object | Kernel bound to this session | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_delete_session` + +Delete a session on a Jupyter server (does not shut down its kernel) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `sessionId` | string | Yes | ID of the session to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the session was deleted | +| `sessionId` | string | Deleted session ID | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index ae36529830d..85ab2e676bc 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -116,6 +116,7 @@ "jina", "jira", "jira_service_management", + "jupyter", "kalshi", "ketch", "knowledge", diff --git a/apps/sim/app/api/tools/jupyter/proxy/route.ts b/apps/sim/app/api/tools/jupyter/proxy/route.ts new file mode 100644 index 00000000000..d30a68a4a49 --- /dev/null +++ b/apps/sim/app/api/tools/jupyter/proxy/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jupyterProxyContract } from '@/lib/api/contracts/tools/jupyter' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + assertSafeJupyterProxyPath, + buildJupyterAuthHeaders, + normalizeJupyterServerUrl, + UnsafeJupyterPathError, +} from '@/tools/jupyter/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JupyterProxyAPI') + +/** + * Proxies Contents/Kernels/Kernelspecs/Sessions API calls to a self-hosted + * Jupyter server. Self-hosted servers have no fixed public host, so every + * request is server-side (DNS-pinned, http(s) allowed, redirects disabled) + * rather than going through the generic external tool executor, which blocks + * plain-HTTP and private-IP hosts by default. Mirrors the upstream status + * and body verbatim so callers can treat this exactly like a direct fetch. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Jupyter proxy attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(jupyterProxyContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + try { + assertSafeJupyterProxyPath(data.path) + } catch (error) { + if (error instanceof UnsafeJupyterPathError) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }) + } + throw error + } + + const base = normalizeJupyterServerUrl(data.serverUrl) + const url = `${base}/api/${data.path}` + + const urlValidation = await validateUrlWithDNS(url, 'serverUrl', { allowHttp: true }) + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json( + { success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` }, + { status: 400 } + ) + } + + const hasBody = data.body !== undefined && data.body !== null + + const upstream = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { + method: data.method, + headers: { + ...buildJupyterAuthHeaders(data.token), + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + }, + body: hasBody ? JSON.stringify(data.body) : undefined, + allowHttp: true, + maxRedirects: 0, + }) + + const text = await upstream.text() + + return new NextResponse(text.length > 0 ? text : null, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }, + }) + } catch (error) { + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Unknown error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts new file mode 100644 index 00000000000..3c12cb21bbf --- /dev/null +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -0,0 +1,151 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jupyterUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, + UnsafeJupyterPathError, +} from '@/tools/jupyter/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JupyterUploadAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Jupyter upload attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(jupyterUploadContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + let fileBuffer: Buffer + let fileName: string + + if (data.file) { + const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + const userFile = userFiles[0] + + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + try { + const result = await downloadServableFileFromStorage(userFile, requestId, logger) + fileBuffer = result.buffer + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to download file') }, + { status: 500 } + ) + } + fileName = data.fileName || userFile.name + } else if (data.fileContent) { + fileBuffer = Buffer.from(data.fileContent, 'base64') + fileName = data.fileName || 'file' + } else { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + if (/[/\\]/.test(fileName)) { + return NextResponse.json( + { success: false, error: 'File name must not contain path separators' }, + { status: 400 } + ) + } + + const base = normalizeJupyterServerUrl(data.serverUrl) + const destinationDirectory = (data.directory ?? '').replace(/\/+$/, '') + const destinationPath = destinationDirectory ? `${destinationDirectory}/${fileName}` : fileName + + let encodedDestinationPath: string + try { + encodedDestinationPath = encodeJupyterPath(destinationPath) + } catch (error) { + if (error instanceof UnsafeJupyterPathError) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }) + } + throw error + } + const uploadUrl = `${base}/api/contents/${encodedDestinationPath}` + + const urlValidation = await validateUrlWithDNS(uploadUrl, 'serverUrl', { allowHttp: true }) + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json( + { success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` }, + { status: 400 } + ) + } + + const response = await secureFetchWithPinnedIP(uploadUrl, urlValidation.resolvedIP, { + method: 'PUT', + headers: { + ...buildJupyterAuthHeaders(data.token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'file', + format: 'base64', + content: fileBuffer.toString('base64'), + }), + allowHttp: true, + maxRedirects: 0, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Jupyter API error:`, { status: response.status, errorText }) + return NextResponse.json( + { success: false, error: `Jupyter API error: ${response.status} ${errorText}` }, + { status: response.status } + ) + } + + const uploaded = await response.json() + + logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`) + + return NextResponse.json({ + success: true, + output: { + name: uploaded.name ?? fileName, + path: uploaded.path ?? destinationPath, + size: uploaded.size ?? fileBuffer.length, + lastModified: uploaded.last_modified ?? null, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Unknown error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/jupyter.ts b/apps/sim/blocks/blocks/jupyter.ts new file mode 100644 index 00000000000..2febc557c73 --- /dev/null +++ b/apps/sim/blocks/blocks/jupyter.ts @@ -0,0 +1,417 @@ +import { JupyterIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' + +const PATH_OPERATIONS = [ + 'jupyter_list_contents', + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_create_session', +] as const + +const REQUIRED_PATH_OPERATIONS = [ + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_create_session', +] as const + +const KERNEL_ID_OPERATIONS = [ + 'jupyter_stop_kernel', + 'jupyter_restart_kernel', + 'jupyter_interrupt_kernel', +] as const + +export const JupyterBlock: BlockConfig = { + type: 'jupyter', + name: 'Jupyter', + description: 'Manage files, notebooks, kernels, and sessions on a Jupyter server', + longDescription: + 'Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels.', + docsLink: 'https://docs.sim.ai/integrations/jupyter', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#FFFFFF', + icon: JupyterIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Contents', id: 'jupyter_list_contents' }, + { label: 'Get Content', id: 'jupyter_get_content' }, + { label: 'Create File', id: 'jupyter_create_file' }, + { label: 'Upload File', id: 'jupyter_upload_file' }, + { label: 'Rename Content', id: 'jupyter_rename_content' }, + { label: 'Delete Content', id: 'jupyter_delete_content' }, + { label: 'Copy Content', id: 'jupyter_copy_content' }, + { label: 'List Kernels', id: 'jupyter_list_kernels' }, + { label: 'Start Kernel', id: 'jupyter_start_kernel' }, + { label: 'Stop Kernel', id: 'jupyter_stop_kernel' }, + { label: 'Restart Kernel', id: 'jupyter_restart_kernel' }, + { label: 'Interrupt Kernel', id: 'jupyter_interrupt_kernel' }, + { label: 'List Kernel Specs', id: 'jupyter_list_kernelspecs' }, + { label: 'List Sessions', id: 'jupyter_list_sessions' }, + { label: 'Create Session', id: 'jupyter_create_session' }, + { label: 'Delete Session', id: 'jupyter_delete_session' }, + ], + value: () => 'jupyter_list_contents', + }, + { + id: 'serverUrl', + title: 'Server URL', + type: 'short-input', + placeholder: 'http://localhost:8888', + required: true, + }, + { + id: 'token', + title: 'Token', + type: 'short-input', + placeholder: 'Enter your Jupyter server token', + password: true, + required: true, + }, + + // Path (shared across contents operations) + { + id: 'path', + title: 'Path', + type: 'short-input', + placeholder: 'notebooks/analysis.ipynb', + condition: { field: 'operation', value: [...PATH_OPERATIONS] }, + required: { field: 'operation', value: [...REQUIRED_PATH_OPERATIONS] }, + }, + + // Create File + { + id: 'type', + title: 'Type', + type: 'dropdown', + options: [ + { label: 'File', id: 'file' }, + { label: 'Notebook', id: 'notebook' }, + { label: 'Directory', id: 'directory' }, + ], + value: () => 'file', + condition: { field: 'operation', value: 'jupyter_create_file' }, + required: { field: 'operation', value: 'jupyter_create_file' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + placeholder: 'File text, or a JSON-stringified notebook document', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_file' }, + }, + + // Upload File + { + id: 'directory', + title: 'Directory', + type: 'short-input', + placeholder: 'notebooks (leave blank for the root directory)', + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload file to send to Jupyter', + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'jupyter_upload_file' }, + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + { + id: 'fileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'Reference file from previous blocks', + mode: 'advanced', + required: { field: 'operation', value: 'jupyter_upload_file' }, + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + { + id: 'uploadFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional filename override', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + + // Rename Content + { + id: 'newPath', + title: 'New Path', + type: 'short-input', + placeholder: 'notebooks/renamed.ipynb', + condition: { field: 'operation', value: 'jupyter_rename_content' }, + required: { field: 'operation', value: 'jupyter_rename_content' }, + }, + + // Copy Content + { + id: 'copyFromPath', + title: 'Copy From Path', + type: 'short-input', + placeholder: 'notebooks/source.ipynb', + condition: { field: 'operation', value: 'jupyter_copy_content' }, + required: { field: 'operation', value: 'jupyter_copy_content' }, + }, + + // Kernels + { + id: 'kernelName', + title: 'Kernel Name', + type: 'short-input', + placeholder: 'python3', + condition: { field: 'operation', value: ['jupyter_start_kernel', 'jupyter_create_session'] }, + }, + { + id: 'kernelId', + title: 'Kernel ID', + type: 'short-input', + placeholder: 'Enter kernel ID', + condition: { field: 'operation', value: [...KERNEL_ID_OPERATIONS] }, + required: { field: 'operation', value: [...KERNEL_ID_OPERATIONS] }, + }, + + // Sessions + { + id: 'sessionName', + title: 'Session Name', + type: 'short-input', + placeholder: 'Optional session name', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_session' }, + }, + { + id: 'sessionType', + title: 'Session Type', + type: 'short-input', + placeholder: 'notebook', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_session' }, + }, + { + id: 'sessionId', + title: 'Session ID', + type: 'short-input', + placeholder: 'Enter session ID', + condition: { field: 'operation', value: 'jupyter_delete_session' }, + required: { field: 'operation', value: 'jupyter_delete_session' }, + }, + ], + + tools: { + access: [ + 'jupyter_list_contents', + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_upload_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_list_kernels', + 'jupyter_start_kernel', + 'jupyter_stop_kernel', + 'jupyter_restart_kernel', + 'jupyter_interrupt_kernel', + 'jupyter_list_kernelspecs', + 'jupyter_list_sessions', + 'jupyter_create_session', + 'jupyter_delete_session', + ], + config: { + tool: (params) => params.operation as string, + params: (params) => { + const normalizedFile = normalizeFileInput(params.file, { single: true }) + if (normalizedFile) { + params.file = normalizedFile + } + + const { operation, ...rest } = params + + const baseParams: Record = { + serverUrl: rest.serverUrl, + token: rest.token, + } + + switch (operation) { + case 'jupyter_list_contents': + if (rest.path) baseParams.path = rest.path + break + case 'jupyter_get_content': + case 'jupyter_delete_content': + baseParams.path = rest.path + break + case 'jupyter_create_file': + baseParams.path = rest.path + baseParams.type = rest.type + if (rest.content) baseParams.content = rest.content + break + case 'jupyter_upload_file': + if (rest.directory) baseParams.directory = rest.directory + baseParams.file = rest.file + if (rest.uploadFileName) baseParams.fileName = rest.uploadFileName + break + case 'jupyter_rename_content': + baseParams.path = rest.path + baseParams.newPath = rest.newPath + break + case 'jupyter_copy_content': + baseParams.path = rest.path + baseParams.copyFromPath = rest.copyFromPath + break + case 'jupyter_start_kernel': + if (rest.kernelName) baseParams.kernelName = rest.kernelName + break + case 'jupyter_stop_kernel': + case 'jupyter_restart_kernel': + case 'jupyter_interrupt_kernel': + baseParams.kernelId = rest.kernelId + break + case 'jupyter_create_session': + baseParams.path = rest.path + if (rest.kernelName) baseParams.kernelName = rest.kernelName + if (rest.sessionName) baseParams.name = rest.sessionName + if (rest.sessionType) baseParams.type = rest.sessionType + break + case 'jupyter_delete_session': + baseParams.sessionId = rest.sessionId + break + } + + return baseParams + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + serverUrl: { type: 'string', description: 'Jupyter server base URL' }, + token: { type: 'string', description: 'Jupyter server authentication token' }, + path: { type: 'string', description: 'Path relative to the server root' }, + type: { type: 'string', description: 'file, notebook, or directory' }, + content: { type: 'string', description: 'File or notebook content' }, + directory: { type: 'string', description: 'Upload destination directory' }, + file: { type: 'json', description: 'File to upload (canonical param)' }, + uploadFileName: { type: 'string', description: 'Optional filename override' }, + newPath: { type: 'string', description: 'New path for rename/move' }, + copyFromPath: { type: 'string', description: 'Source path to copy from' }, + kernelName: { type: 'string', description: 'Kernel spec name' }, + kernelId: { type: 'string', description: 'Kernel ID' }, + sessionName: { type: 'string', description: 'Session name' }, + sessionType: { type: 'string', description: 'Session type' }, + sessionId: { type: 'string', description: 'Session ID' }, + }, + + outputs: { + items: 'json', + path: 'string', + name: 'string', + type: 'string', + mimetype: 'string', + text: 'string', + file: 'file', + size: 'number', + createdAt: 'string', + lastModified: 'string', + success: 'boolean', + id: 'string', + lastActivity: 'string', + executionState: 'string', + connections: 'number', + kernels: 'json', + defaultKernelName: 'string', + kernelspecs: 'json', + sessions: 'json', + kernel: 'json', + kernelId: 'string', + sessionId: 'string', + }, +} + +export const JupyterBlockMeta = { + tags: ['automation', 'data-analytics'], + templates: [ + { + icon: JupyterIcon, + title: 'Jupyter notebook creator', + prompt: + 'Build a workflow that generates a Jupyter notebook from a data analysis request and creates it on a self-hosted Jupyter server.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Upload datasets to Jupyter', + prompt: + 'Build a workflow that uploads a file from a Table row to a Jupyter server as a dataset for later analysis.', + modules: ['tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Jupyter kernel health check', + prompt: + 'Build a scheduled workflow that lists running Jupyter kernels, restarts any kernel stuck in a busy state, and posts a summary to Slack.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['automation', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: JupyterIcon, + title: 'Notebook directory sync report', + prompt: + 'Build a workflow that lists the contents of a Jupyter server directory and writes a summary of files and notebooks to a Table.', + modules: ['tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Read notebook and summarize', + prompt: + 'Build a workflow that reads a Jupyter notebook, has an agent summarize its cells, and sends the summary in Chat.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Provision a Jupyter session', + prompt: + 'Build a workflow that creates a Jupyter session bound to a new notebook and a fresh Python kernel whenever a new project request comes in.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Archive and clean up notebooks', + prompt: + 'Build a scheduled workflow that copies old notebooks on a Jupyter server into an archive directory, then deletes the originals.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index fd1d8de138a..89919671fc9 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -156,6 +156,7 @@ import { JiraServiceManagementBlock, JiraServiceManagementBlockMeta, } from '@/blocks/blocks/jira_service_management' +import { JupyterBlock, JupyterBlockMeta } from '@/blocks/blocks/jupyter' import { KalshiBlock, KalshiBlockMeta, @@ -477,6 +478,7 @@ export const BLOCK_REGISTRY: Record = { jina: JinaBlock, jira: JiraBlock, jira_service_management: JiraServiceManagementBlock, + jupyter: JupyterBlock, kalshi: KalshiBlock, kalshi_v2: KalshiV2Block, ketch: KetchBlock, @@ -769,6 +771,7 @@ export const BLOCK_META_REGISTRY: Record = { jina: JinaBlockMeta, jira: JiraBlockMeta, jira_service_management: JiraServiceManagementBlockMeta, + jupyter: JupyterBlockMeta, kalshi: KalshiBlockMeta, kalshi_v2: KalshiV2BlockMeta, ketch: KetchBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index ea1eeea8a10..c213134b0e1 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8412,3 +8412,72 @@ export function FlowiseIcon(props: SVGProps) { ) } + +export function JupyterIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index c9f98526c5a..4a421203e4a 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -107,6 +107,15 @@ export const dropboxUploadBodySchema = z.object({ mute: z.boolean().optional().nullable(), }) +export const jupyterUploadBodySchema = z.object({ + serverUrl: z.string().min(1, 'Server URL is required'), + token: z.string().min(1, 'Token is required'), + directory: z.string().optional().nullable(), + file: FileInputSchema.optional().nullable(), + fileContent: z.string().optional().nullable(), + fileName: z.string().optional().nullable(), +}) + export const wordpressUploadBodySchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), siteId: z.string().min(1, 'Site ID is required'), @@ -483,6 +492,13 @@ export const dropboxUploadContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) +export const jupyterUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/jupyter/upload', + body: jupyterUploadBodySchema, + response: { mode: 'json', schema: jsonResponseSchema }, +}) + export const wordpressUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/wordpress/upload', @@ -733,6 +749,8 @@ export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput export type DropboxUploadResponse = ContractJsonResponse +export type JupyterUploadBody = ContractBodyInput +export type JupyterUploadResponse = ContractJsonResponse export type WordPressUploadBody = ContractBodyInput export type WordPressUploadResponse = ContractJsonResponse export type SftpDownloadBody = ContractBodyInput diff --git a/apps/sim/lib/api/contracts/tools/jupyter.ts b/apps/sim/lib/api/contracts/tools/jupyter.ts new file mode 100644 index 00000000000..5f13d2360df --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/jupyter.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const jupyterProxyBodySchema = z.object({ + serverUrl: z.string().min(1, 'Server URL is required'), + token: z.string().min(1, 'Token is required'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().min(1, 'Path is required'), + body: z.unknown().optional().nullable(), +}) + +export const jupyterProxyContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/jupyter/proxy', + body: jupyterProxyBodySchema, + // untyped-response: the route mirrors the upstream Jupyter server's response + // verbatim (status + body), which varies per Contents/Kernels/Sessions endpoint + response: { mode: 'json', schema: z.unknown() }, +}) + +export type JupyterProxyBody = ContractBodyInput +export type JupyterProxyResponse = ContractJsonResponse diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index e9df1348307..f7c5ddf8d19 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -114,6 +114,7 @@ import { JinaAIIcon, JiraIcon, JiraServiceManagementIcon, + JupyterIcon, KalshiIcon, KetchIcon, LangsmithIcon, @@ -349,6 +350,7 @@ export const blockTypeToIconMap: Record = { jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, + jupyter: JupyterIcon, kalshi_v2: KalshiIcon, ketch: KetchIcon, knowledge: PackageSearchIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9d543582283..8e741d71e57 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-07", + "updatedAt": "2026-07-09", "integrations": [ { "type": "onepassword", @@ -9749,6 +9749,89 @@ "integrationType": "support", "tags": ["customer-support", "ticketing", "incident-management"] }, + { + "type": "jupyter", + "slug": "jupyter", + "name": "Jupyter", + "description": "Manage files, notebooks, kernels, and sessions on a Jupyter server", + "longDescription": "Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels.", + "bgColor": "#FFFFFF", + "iconName": "JupyterIcon", + "docsUrl": "https://docs.sim.ai/integrations/jupyter", + "operations": [ + { + "name": "List Contents", + "description": "List files, notebooks, and subdirectories at a path on a Jupyter server" + }, + { + "name": "Get Content", + "description": "Read a file or notebook from a Jupyter server" + }, + { + "name": "Create File", + "description": "Create a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Upload File", + "description": "Upload a file to a Jupyter server" + }, + { + "name": "Rename Content", + "description": "Rename or move a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Delete Content", + "description": "Delete a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Copy Content", + "description": "Duplicate a file or notebook into a directory on a Jupyter server" + }, + { + "name": "List Kernels", + "description": "List running kernels on a Jupyter server" + }, + { + "name": "Start Kernel", + "description": "Start a new kernel on a Jupyter server" + }, + { + "name": "Stop Kernel", + "description": "Shut down a running kernel on a Jupyter server" + }, + { + "name": "Restart Kernel", + "description": "Restart a running kernel on a Jupyter server" + }, + { + "name": "Interrupt Kernel", + "description": "Interrupt a running kernel on a Jupyter server" + }, + { + "name": "List Kernel Specs", + "description": "List available kernel specs (languages/runtimes) on a Jupyter server" + }, + { + "name": "List Sessions", + "description": "List active sessions (notebook-to-kernel bindings) on a Jupyter server" + }, + { + "name": "Create Session", + "description": "Create a session that binds a notebook path to a (new or existing) kernel" + }, + { + "name": "Delete Session", + "description": "Delete a session on a Jupyter server (does not shut down its kernel)" + } + ], + "operationCount": 16, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "devops", + "tags": ["automation", "data-analytics"] + }, { "type": "kalshi_v2", "slug": "kalshi", diff --git a/apps/sim/tools/jupyter/copy_content.ts b/apps/sim/tools/jupyter/copy_content.ts new file mode 100644 index 00000000000..87abbdbd0d3 --- /dev/null +++ b/apps/sim/tools/jupyter/copy_content.ts @@ -0,0 +1,81 @@ +import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types' +import { assertSafeJupyterPath, encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterCopyContentTool: ToolConfig< + JupyterCopyContentParams, + JupyterCopyContentResponse +> = { + id: 'jupyter_copy_content', + name: 'Jupyter Copy Content', + description: 'Duplicate a file or notebook into a directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Destination directory path, relative to the server root', + }, + copyFromPath: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the file or notebook to copy, relative to the server root', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `contents/${encodeJupyterPath(params.path)}`, + body: { copy_from: assertSafeJupyterPath(params.copyFromPath) }, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { name: '', path: params?.path ?? '', createdAt: null }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.path ?? '', + createdAt: (data.created as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the copied entry' }, + path: { type: 'string', description: 'Path of the copied entry' }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/create_file.ts b/apps/sim/tools/jupyter/create_file.ts new file mode 100644 index 00000000000..5d59dfcff20 --- /dev/null +++ b/apps/sim/tools/jupyter/create_file.ts @@ -0,0 +1,119 @@ +import type { JupyterCreateFileParams, JupyterCreateFileResponse } from '@/tools/jupyter/types' +import { encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +const EMPTY_NOTEBOOK = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 } + +export const jupyterCreateFileTool: ToolConfig = + { + id: 'jupyter_create_file', + name: 'Jupyter Create File', + description: 'Create a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to create, relative to the server root', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of entry to create: file, notebook, or directory', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Content to write. For a file, plain text. For a notebook, a JSON-stringified nbformat document (defaults to an empty notebook). Ignored for directories.', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + let content: Record + if (params.type === 'directory') { + content = { type: 'directory' } + } else if (params.type === 'notebook') { + if (!params.content) { + content = { type: 'notebook', format: 'json', content: EMPTY_NOTEBOOK } + } else { + let notebook: unknown + try { + notebook = JSON.parse(params.content) + } catch { + throw new Error('Notebook content must be valid JSON-stringified nbformat') + } + content = { type: 'notebook', format: 'json', content: notebook } + } + } else { + content = { type: 'file', format: 'text', content: params.content ?? '' } + } + + return { + serverUrl: params.serverUrl, + token: params.token, + method: 'PUT', + path: `contents/${encodeJupyterPath(params.path)}`, + body: content, + } + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { + name: '', + path: params?.path ?? '', + type: params?.type ?? 'file', + createdAt: null, + lastModified: null, + }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.path ?? '', + type: (data.type as 'directory' | 'file' | 'notebook' | undefined) ?? 'file', + createdAt: (data.created as string | undefined) ?? null, + lastModified: (data.last_modified as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Created entry name' }, + path: { type: 'string', description: 'Created entry path' }, + type: { type: 'string', description: 'directory, file, or notebook' }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, + } diff --git a/apps/sim/tools/jupyter/create_session.ts b/apps/sim/tools/jupyter/create_session.ts new file mode 100644 index 00000000000..9a12123b50f --- /dev/null +++ b/apps/sim/tools/jupyter/create_session.ts @@ -0,0 +1,110 @@ +import type { + JupyterCreateSessionParams, + JupyterCreateSessionResponse, +} from '@/tools/jupyter/types' +import { assertSafeJupyterPath, mapJupyterSession } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterCreateSessionTool: ToolConfig< + JupyterCreateSessionParams, + JupyterCreateSessionResponse +> = { + id: 'jupyter_create_session', + name: 'Jupyter Create Session', + description: 'Create a session that binds a notebook path to a (new or existing) kernel', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Notebook path to bind the session to, relative to the server root', + }, + kernelName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Kernel spec name to start for this session (e.g. python3)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional session name', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Session type, defaults to 'notebook'", + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: 'sessions', + body: { + path: assertSafeJupyterPath(params.path), + name: params.name, + type: params.type || 'notebook', + ...(params.kernelName ? { kernel: { name: params.kernelName } } : {}), + }, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterSession(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Session ID' }, + path: { type: 'string', description: 'Notebook path bound to this session' }, + name: { type: 'string', description: 'Session name' }, + type: { type: 'string', description: 'Session type' }, + kernel: { + type: 'object', + description: 'Kernel bound to this session', + optional: true, + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { + type: 'string', + description: 'Kernel execution state', + optional: true, + }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/delete_content.ts b/apps/sim/tools/jupyter/delete_content.ts new file mode 100644 index 00000000000..345d410acd3 --- /dev/null +++ b/apps/sim/tools/jupyter/delete_content.ts @@ -0,0 +1,70 @@ +import type { + JupyterDeleteContentParams, + JupyterDeleteContentResponse, +} from '@/tools/jupyter/types' +import { encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterDeleteContentTool: ToolConfig< + JupyterDeleteContentParams, + JupyterDeleteContentResponse +> = { + id: 'jupyter_delete_content', + name: 'Jupyter Delete Content', + description: 'Delete a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the entry to delete, relative to the server root', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `contents/${encodeJupyterPath(params.path)}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, path: params?.path ?? '' }, + } + } + + return { + success: true, + output: { success: true, path: params?.path ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the entry was deleted' }, + path: { type: 'string', description: 'Deleted entry path' }, + }, +} diff --git a/apps/sim/tools/jupyter/delete_session.ts b/apps/sim/tools/jupyter/delete_session.ts new file mode 100644 index 00000000000..58afd30e1fc --- /dev/null +++ b/apps/sim/tools/jupyter/delete_session.ts @@ -0,0 +1,69 @@ +import type { + JupyterDeleteSessionParams, + JupyterDeleteSessionResponse, +} from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterDeleteSessionTool: ToolConfig< + JupyterDeleteSessionParams, + JupyterDeleteSessionResponse +> = { + id: 'jupyter_delete_session', + name: 'Jupyter Delete Session', + description: 'Delete a session on a Jupyter server (does not shut down its kernel)', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + sessionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the session to delete', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `sessions/${encodeURIComponent(params.sessionId)}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, sessionId: params?.sessionId ?? '' }, + } + } + + return { + success: true, + output: { success: true, sessionId: params?.sessionId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the session was deleted' }, + sessionId: { type: 'string', description: 'Deleted session ID' }, + }, +} diff --git a/apps/sim/tools/jupyter/get_content.ts b/apps/sim/tools/jupyter/get_content.ts new file mode 100644 index 00000000000..55f0a31cba3 --- /dev/null +++ b/apps/sim/tools/jupyter/get_content.ts @@ -0,0 +1,117 @@ +import type { JupyterGetContentParams, JupyterGetContentResponse } from '@/tools/jupyter/types' +import { encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterGetContentTool: ToolConfig = + { + id: 'jupyter_get_content', + name: 'Jupyter Get Content', + description: 'Read a file or notebook from a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the file or notebook to read, relative to the server root', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: `contents/${encodeJupyterPath(params.path)}?content=1`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { + name: '', + path: params?.path ?? '', + mimetype: null, + text: null, + file: null, + }, + } + } + + const data = await response.json() + const format = data.format as string | undefined + const name = (data.name as string | undefined) ?? params?.path?.split('/').pop() ?? 'file' + const mimetype = (data.mimetype as string | undefined) ?? null + + if (format === 'base64' && typeof data.content === 'string') { + const buffer = Buffer.from(data.content, 'base64') + return { + success: true, + output: { + name, + path: (data.path as string | undefined) ?? params?.path ?? '', + mimetype, + text: null, + file: { + name, + mimeType: mimetype ?? 'application/octet-stream', + data: data.content, + size: buffer.length, + }, + }, + } + } + + const text = + format === 'json' || typeof data.content === 'object' + ? JSON.stringify(data.content) + : ((data.content as string | undefined) ?? null) + + return { + success: true, + output: { + name, + path: (data.path as string | undefined) ?? params?.path ?? '', + mimetype, + text, + file: null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'File or notebook name' }, + path: { type: 'string', description: 'Path relative to the server root' }, + mimetype: { type: 'string', description: 'MIME type of the content', optional: true }, + text: { + type: 'string', + description: 'Text content, for text files and notebooks (JSON-stringified)', + optional: true, + }, + file: { + type: 'file', + description: 'Binary content stored as a file, for base64-format content', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/jupyter/index.ts b/apps/sim/tools/jupyter/index.ts new file mode 100644 index 00000000000..9ddfd7e8021 --- /dev/null +++ b/apps/sim/tools/jupyter/index.ts @@ -0,0 +1,16 @@ +export { jupyterCopyContentTool } from '@/tools/jupyter/copy_content' +export { jupyterCreateFileTool } from '@/tools/jupyter/create_file' +export { jupyterCreateSessionTool } from '@/tools/jupyter/create_session' +export { jupyterDeleteContentTool } from '@/tools/jupyter/delete_content' +export { jupyterDeleteSessionTool } from '@/tools/jupyter/delete_session' +export { jupyterGetContentTool } from '@/tools/jupyter/get_content' +export { jupyterInterruptKernelTool } from '@/tools/jupyter/interrupt_kernel' +export { jupyterListContentsTool } from '@/tools/jupyter/list_contents' +export { jupyterListKernelsTool } from '@/tools/jupyter/list_kernels' +export { jupyterListKernelspecsTool } from '@/tools/jupyter/list_kernelspecs' +export { jupyterListSessionsTool } from '@/tools/jupyter/list_sessions' +export { jupyterRenameContentTool } from '@/tools/jupyter/rename_content' +export { jupyterRestartKernelTool } from '@/tools/jupyter/restart_kernel' +export { jupyterStartKernelTool } from '@/tools/jupyter/start_kernel' +export { jupyterStopKernelTool } from '@/tools/jupyter/stop_kernel' +export { jupyterUploadFileTool } from '@/tools/jupyter/upload_file' diff --git a/apps/sim/tools/jupyter/interrupt_kernel.ts b/apps/sim/tools/jupyter/interrupt_kernel.ts new file mode 100644 index 00000000000..c5371a84bbc --- /dev/null +++ b/apps/sim/tools/jupyter/interrupt_kernel.ts @@ -0,0 +1,69 @@ +import type { + JupyterInterruptKernelParams, + JupyterInterruptKernelResponse, +} from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterInterruptKernelTool: ToolConfig< + JupyterInterruptKernelParams, + JupyterInterruptKernelResponse +> = { + id: 'jupyter_interrupt_kernel', + name: 'Jupyter Interrupt Kernel', + description: 'Interrupt a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to interrupt', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `kernels/${encodeURIComponent(params.kernelId)}/interrupt`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, kernelId: params?.kernelId ?? '' }, + } + } + + return { + success: true, + output: { success: true, kernelId: params?.kernelId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the interrupt was sent' }, + kernelId: { type: 'string', description: 'Interrupted kernel ID' }, + }, +} diff --git a/apps/sim/tools/jupyter/list_contents.ts b/apps/sim/tools/jupyter/list_contents.ts new file mode 100644 index 00000000000..f8f8cce2fe0 --- /dev/null +++ b/apps/sim/tools/jupyter/list_contents.ts @@ -0,0 +1,107 @@ +import type { JupyterListContentsParams, JupyterListContentsResponse } from '@/tools/jupyter/types' +import { encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListContentsTool: ToolConfig< + JupyterListContentsParams, + JupyterListContentsResponse +> = { + id: 'jupyter_list_contents', + name: 'Jupyter List Contents', + description: 'List files, notebooks, and subdirectories at a path on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Directory path to list, relative to the server root. Leave blank for root.', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: `contents/${encodeJupyterPath(params.path)}?type=directory&content=1`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { items: [], path: params?.path ?? '' }, + } + } + + const data = await response.json() + const items = Array.isArray(data.content) ? data.content : [] + + return { + success: true, + output: { + items: items.map((item: Record) => ({ + name: item.name, + path: item.path, + type: item.type, + writable: Boolean(item.writable), + created: (item.created as string | undefined) ?? null, + lastModified: (item.last_modified as string | undefined) ?? null, + size: (item.size as number | undefined) ?? null, + mimetype: (item.mimetype as string | undefined) ?? null, + format: (item.format as string | undefined) ?? null, + })), + path: (data.path as string | undefined) ?? params?.path ?? '', + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Directory entries at the requested path', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Entry name' }, + path: { type: 'string', description: 'Entry path relative to server root' }, + type: { type: 'string', description: 'directory, file, or notebook' }, + writable: { type: 'boolean', description: 'Whether the entry is writable' }, + created: { type: 'string', description: 'Creation timestamp', optional: true }, + lastModified: { + type: 'string', + description: 'Last modified timestamp', + optional: true, + }, + size: { type: 'number', description: 'Size in bytes', optional: true }, + mimetype: { type: 'string', description: 'MIME type (files only)', optional: true }, + format: { type: 'string', description: 'json, text, or base64', optional: true }, + }, + }, + }, + path: { + type: 'string', + description: 'The listed directory path', + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_kernels.ts b/apps/sim/tools/jupyter/list_kernels.ts new file mode 100644 index 00000000000..c5a93c45794 --- /dev/null +++ b/apps/sim/tools/jupyter/list_kernels.ts @@ -0,0 +1,76 @@ +import type { JupyterListKernelsParams, JupyterListKernelsResponse } from '@/tools/jupyter/types' +import { mapJupyterKernel } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListKernelsTool: ToolConfig< + JupyterListKernelsParams, + JupyterListKernelsResponse +> = { + id: 'jupyter_list_kernels', + name: 'Jupyter List Kernels', + description: 'List running kernels on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'kernels', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { kernels: [] }, + } + } + + const data = await response.json() + const kernels = Array.isArray(data) ? data : [] + + return { + success: true, + output: { kernels: kernels.map(mapJupyterKernel) }, + } + }, + + outputs: { + kernels: { + type: 'array', + description: 'Running kernels', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_kernelspecs.ts b/apps/sim/tools/jupyter/list_kernelspecs.ts new file mode 100644 index 00000000000..5c3bb39e72d --- /dev/null +++ b/apps/sim/tools/jupyter/list_kernelspecs.ts @@ -0,0 +1,93 @@ +import type { + JupyterListKernelspecsParams, + JupyterListKernelspecsResponse, +} from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListKernelspecsTool: ToolConfig< + JupyterListKernelspecsParams, + JupyterListKernelspecsResponse +> = { + id: 'jupyter_list_kernelspecs', + name: 'Jupyter List Kernel Specs', + description: 'List available kernel specs (languages/runtimes) on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'kernelspecs', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { defaultKernelName: null, kernelspecs: [] }, + } + } + + const data = await response.json() + const specs = data.kernelspecs && typeof data.kernelspecs === 'object' ? data.kernelspecs : {} + + return { + success: true, + output: { + defaultKernelName: (data.default as string | undefined) ?? null, + kernelspecs: Object.entries(specs as Record>).map( + ([name, entry]) => { + const spec = (entry.spec as Record | undefined) ?? {} + return { + name: (entry.name as string | undefined) ?? name, + displayName: (spec.display_name as string | undefined) ?? name, + language: (spec.language as string | undefined) ?? null, + argv: Array.isArray(spec.argv) ? (spec.argv as string[]) : [], + interruptMode: (spec.interrupt_mode as string | undefined) ?? null, + } + } + ), + }, + } + }, + + outputs: { + defaultKernelName: { type: 'string', description: 'Default kernel spec name', optional: true }, + kernelspecs: { + type: 'array', + description: 'Available kernel specs', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Kernel spec name' }, + displayName: { type: 'string', description: 'Human-readable display name' }, + language: { type: 'string', description: 'Kernel language', optional: true }, + argv: { type: 'array', description: 'Launch command arguments' }, + interruptMode: { type: 'string', description: 'Interrupt mode', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_sessions.ts b/apps/sim/tools/jupyter/list_sessions.ts new file mode 100644 index 00000000000..5081f649e1c --- /dev/null +++ b/apps/sim/tools/jupyter/list_sessions.ts @@ -0,0 +1,99 @@ +import type { JupyterListSessionsParams, JupyterListSessionsResponse } from '@/tools/jupyter/types' +import { mapJupyterSession } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListSessionsTool: ToolConfig< + JupyterListSessionsParams, + JupyterListSessionsResponse +> = { + id: 'jupyter_list_sessions', + name: 'Jupyter List Sessions', + description: 'List active sessions (notebook-to-kernel bindings) on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'sessions', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { sessions: [] }, + } + } + + const data = await response.json() + const sessions = Array.isArray(data) ? data : [] + + return { + success: true, + output: { sessions: sessions.map(mapJupyterSession) }, + } + }, + + outputs: { + sessions: { + type: 'array', + description: 'Active sessions', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Session ID' }, + path: { type: 'string', description: 'Notebook path bound to this session' }, + name: { type: 'string', description: 'Session name' }, + type: { type: 'string', description: 'Session type' }, + kernel: { + type: 'object', + description: 'Kernel bound to this session', + optional: true, + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { + type: 'string', + description: 'Last activity timestamp', + optional: true, + }, + executionState: { + type: 'string', + description: 'Kernel execution state', + optional: true, + }, + connections: { + type: 'number', + description: 'Active connection count', + optional: true, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/rename_content.ts b/apps/sim/tools/jupyter/rename_content.ts new file mode 100644 index 00000000000..2e32900b1e6 --- /dev/null +++ b/apps/sim/tools/jupyter/rename_content.ts @@ -0,0 +1,84 @@ +import type { + JupyterRenameContentParams, + JupyterRenameContentResponse, +} from '@/tools/jupyter/types' +import { assertSafeJupyterPath, encodeJupyterPath } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterRenameContentTool: ToolConfig< + JupyterRenameContentParams, + JupyterRenameContentResponse +> = { + id: 'jupyter_rename_content', + name: 'Jupyter Rename Content', + description: 'Rename or move a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Current path of the entry, relative to the server root', + }, + newPath: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New path for the entry, relative to the server root', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'PATCH', + path: `contents/${encodeJupyterPath(params.path)}`, + body: { path: assertSafeJupyterPath(params.newPath) }, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { name: '', path: params?.newPath ?? '', lastModified: null }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.newPath ?? '', + lastModified: (data.last_modified as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'New entry name' }, + path: { type: 'string', description: 'New entry path' }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/restart_kernel.ts b/apps/sim/tools/jupyter/restart_kernel.ts new file mode 100644 index 00000000000..b70365b02fe --- /dev/null +++ b/apps/sim/tools/jupyter/restart_kernel.ts @@ -0,0 +1,71 @@ +import type { + JupyterRestartKernelParams, + JupyterRestartKernelResponse, +} from '@/tools/jupyter/types' +import { mapJupyterKernel } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterRestartKernelTool: ToolConfig< + JupyterRestartKernelParams, + JupyterRestartKernelResponse +> = { + id: 'jupyter_restart_kernel', + name: 'Jupyter Restart Kernel', + description: 'Restart a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to restart', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `kernels/${encodeURIComponent(params.kernelId)}/restart`, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterKernel(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/start_kernel.ts b/apps/sim/tools/jupyter/start_kernel.ts new file mode 100644 index 00000000000..3c9985eb932 --- /dev/null +++ b/apps/sim/tools/jupyter/start_kernel.ts @@ -0,0 +1,69 @@ +import type { JupyterStartKernelParams, JupyterStartKernelResponse } from '@/tools/jupyter/types' +import { mapJupyterKernel } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterStartKernelTool: ToolConfig< + JupyterStartKernelParams, + JupyterStartKernelResponse +> = { + id: 'jupyter_start_kernel', + name: 'Jupyter Start Kernel', + description: 'Start a new kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Kernel spec name to start (e.g. python3). Defaults to the server default.', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: 'kernels', + body: params.kernelName ? { name: params.kernelName } : {}, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterKernel(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/stop_kernel.ts b/apps/sim/tools/jupyter/stop_kernel.ts new file mode 100644 index 00000000000..261e9f996ef --- /dev/null +++ b/apps/sim/tools/jupyter/stop_kernel.ts @@ -0,0 +1,64 @@ +import type { JupyterStopKernelParams, JupyterStopKernelResponse } from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterStopKernelTool: ToolConfig = + { + id: 'jupyter_stop_kernel', + name: 'Jupyter Stop Kernel', + description: 'Shut down a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to shut down', + }, + }, + + request: { + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `kernels/${encodeURIComponent(params.kernelId)}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, kernelId: params?.kernelId ?? '' }, + } + } + + return { + success: true, + output: { success: true, kernelId: params?.kernelId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the kernel was shut down' }, + kernelId: { type: 'string', description: 'Shut down kernel ID' }, + }, + } diff --git a/apps/sim/tools/jupyter/types.ts b/apps/sim/tools/jupyter/types.ts new file mode 100644 index 00000000000..15a0e570bdb --- /dev/null +++ b/apps/sim/tools/jupyter/types.ts @@ -0,0 +1,226 @@ +import type { ToolResponse } from '@/tools/types' + +export interface JupyterAuthParams { + serverUrl: string + token: string +} + +export interface JupyterContentItem { + name: string + path: string + type: 'directory' | 'file' | 'notebook' + writable: boolean + created: string | null + lastModified: string | null + size: number | null + mimetype: string | null + format: 'json' | 'text' | 'base64' | null +} + +export interface JupyterKernel { + id: string + name: string + lastActivity: string | null + executionState: string | null + connections: number | null +} + +export interface JupyterKernelSpec { + name: string + displayName: string + language: string | null + argv: string[] + interruptMode: string | null +} + +export interface JupyterSession { + id: string + path: string + name: string + type: string + kernel: JupyterKernel | null +} + +export interface JupyterListContentsParams extends JupyterAuthParams { + path?: string +} + +export interface JupyterListContentsResponse extends ToolResponse { + output: { + items: JupyterContentItem[] + path: string + } +} + +export interface JupyterGetContentParams extends JupyterAuthParams { + path: string +} + +export interface JupyterGetContentResponse extends ToolResponse { + output: { + name: string + path: string + mimetype: string | null + text: string | null + file: { + name: string + mimeType: string + data: string + size: number + } | null + } +} + +export interface JupyterCreateFileParams extends JupyterAuthParams { + path: string + type: 'file' | 'notebook' | 'directory' + content?: string +} + +export interface JupyterCreateFileResponse extends ToolResponse { + output: { + name: string + path: string + type: 'directory' | 'file' | 'notebook' + createdAt: string | null + lastModified: string | null + } +} + +export interface JupyterUploadFileParams extends JupyterAuthParams { + directory?: string + file?: unknown + fileContent?: string + fileName?: string +} + +export interface JupyterUploadFileResponse extends ToolResponse { + output: { + name: string + path: string + size: number | null + lastModified: string | null + } +} + +export interface JupyterRenameContentParams extends JupyterAuthParams { + path: string + newPath: string +} + +export interface JupyterRenameContentResponse extends ToolResponse { + output: { + name: string + path: string + lastModified: string | null + } +} + +export interface JupyterDeleteContentParams extends JupyterAuthParams { + path: string +} + +export interface JupyterDeleteContentResponse extends ToolResponse { + output: { + success: boolean + path: string + } +} + +export interface JupyterCopyContentParams extends JupyterAuthParams { + path: string + copyFromPath: string +} + +export interface JupyterCopyContentResponse extends ToolResponse { + output: { + name: string + path: string + createdAt: string | null + } +} + +export interface JupyterListKernelsParams extends JupyterAuthParams {} + +export interface JupyterListKernelsResponse extends ToolResponse { + output: { + kernels: JupyterKernel[] + } +} + +export interface JupyterStartKernelParams extends JupyterAuthParams { + kernelName?: string +} + +export interface JupyterStartKernelResponse extends ToolResponse { + output: JupyterKernel +} + +export interface JupyterStopKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterStopKernelResponse extends ToolResponse { + output: { + success: boolean + kernelId: string + } +} + +export interface JupyterRestartKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterRestartKernelResponse extends ToolResponse { + output: JupyterKernel +} + +export interface JupyterInterruptKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterInterruptKernelResponse extends ToolResponse { + output: { + success: boolean + kernelId: string + } +} + +export interface JupyterListKernelspecsParams extends JupyterAuthParams {} + +export interface JupyterListKernelspecsResponse extends ToolResponse { + output: { + defaultKernelName: string | null + kernelspecs: JupyterKernelSpec[] + } +} + +export interface JupyterListSessionsParams extends JupyterAuthParams {} + +export interface JupyterListSessionsResponse extends ToolResponse { + output: { + sessions: JupyterSession[] + } +} + +export interface JupyterCreateSessionParams extends JupyterAuthParams { + path: string + kernelName?: string + name?: string + type?: string +} + +export interface JupyterCreateSessionResponse extends ToolResponse { + output: JupyterSession +} + +export interface JupyterDeleteSessionParams extends JupyterAuthParams { + sessionId: string +} + +export interface JupyterDeleteSessionResponse extends ToolResponse { + output: { + success: boolean + sessionId: string + } +} diff --git a/apps/sim/tools/jupyter/upload_file.ts b/apps/sim/tools/jupyter/upload_file.ts new file mode 100644 index 00000000000..57894c11486 --- /dev/null +++ b/apps/sim/tools/jupyter/upload_file.ts @@ -0,0 +1,86 @@ +import type { JupyterUploadFileParams, JupyterUploadFileResponse } from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterUploadFileTool: ToolConfig = + { + id: 'jupyter_upload_file', + name: 'Jupyter Upload File', + description: 'Upload a file to a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + directory: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Destination directory, relative to the server root. Leave blank to upload to the root directory.', + }, + file: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'The file to upload (UserFile object)', + }, + fileContent: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Legacy: base64 encoded file content', + }, + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override', + }, + }, + + request: { + url: '/api/tools/jupyter/upload', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + directory: params.directory, + file: params.file, + fileContent: params.fileContent, + fileName: params.fileName, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(data.error || 'Failed to upload file') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + name: { type: 'string', description: 'Uploaded file name' }, + path: { type: 'string', description: 'Uploaded file path' }, + size: { type: 'number', description: 'File size in bytes', optional: true }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, + } diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts new file mode 100644 index 00000000000..517ec545c95 --- /dev/null +++ b/apps/sim/tools/jupyter/utils.ts @@ -0,0 +1,187 @@ +const PROTOCOL_PATTERN = /^https?:\/\//i + +/** + * Error thrown when a user-supplied Jupyter server URL cannot be parsed into a + * safe http(s) origin to target with the caller's token. + */ +export class InvalidJupyterServerUrlError extends Error { + constructor(rawUrl: string) { + super(`Invalid Jupyter server URL: ${rawUrl}`) + this.name = 'InvalidJupyterServerUrlError' + } +} + +/** + * Normalizes a user-supplied Jupyter server URL: trims whitespace, defaults to + * `http://` when no scheme is given (most Jupyter servers run over plain HTTP + * on localhost or a private network), and strips any trailing slash and + * query/fragment. Self-hosted Jupyter servers have no fixed public host, so the + * URL is always user-supplied. + * + * @throws {InvalidJupyterServerUrlError} when the value is empty or not a valid http(s) URL. + */ +export function normalizeJupyterServerUrl(rawUrl: unknown): string { + const raw = typeof rawUrl === 'string' ? rawUrl.trim() : '' + if (!raw) throw new InvalidJupyterServerUrlError(String(rawUrl)) + + const withProtocol = PROTOCOL_PATTERN.test(raw) ? raw : `http://${raw}` + + let parsed: URL + try { + parsed = new URL(withProtocol) + } catch { + throw new InvalidJupyterServerUrlError(raw) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new InvalidJupyterServerUrlError(raw) + } + + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}` +} + +/** + * Builds the `Authorization` header Jupyter Server expects for token auth. + */ +export function buildJupyterAuthHeaders(token: string): Record { + return { Authorization: `token ${token}` } +} + +/** + * Error thrown when a user-supplied Jupyter contents path contains a `.` or + * `..` segment that could traverse outside the intended directory. + */ +export class UnsafeJupyterPathError extends Error { + constructor(rawPath: string) { + super(`Invalid Jupyter path: ${rawPath}`) + this.name = 'UnsafeJupyterPathError' + } +} + +/** + * Rejects `.` and `..` segments in a Jupyter contents path, which could + * otherwise traverse outside the intended directory on the target server. + * Shared by every helper that sends a path to Jupyter, whether in a URL or a + * request body. + * + * Decodes the *entire* path once before splitting it, rather than splitting + * on literal `/` first and decoding each piece in isolation — a segment like + * `foo%2f..%2fsecret` has no literal slash, so a split-then-decode check + * would treat it as one opaque segment and never notice the `..` hiding + * behind the encoded slash. Decoding first exposes every segment the target + * server would actually see once its own single URL-decode pass runs. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`, literally + * or percent-encoded (including an encoded slash exposing a hidden segment). + */ +function assertNoJupyterPathTraversal(path: string | undefined): string[] { + const raw = path ?? '' + + let decoded: string + try { + decoded = decodeURIComponent(raw) + } catch { + decoded = raw + } + + if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) { + throw new UnsafeJupyterPathError(raw) + } + + return raw.split('/').filter((segment) => segment.length > 0) +} + +/** + * Encodes a Jupyter contents path segment-by-segment so slashes stay as path + * separators while special characters within a segment are escaped. Use for + * paths interpolated into a request URL. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. + */ +export function encodeJupyterPath(path: string | undefined): string { + return assertNoJupyterPathTraversal(path).map(encodeURIComponent).join('/') +} + +/** + * Validates a Jupyter contents path with no URL-encoding. Use for paths sent + * as-is in a JSON request body (e.g. a PATCH/POST `path`/`copy_from` field) + * that never flow through `encodeJupyterPath`. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. + */ +export function assertSafeJupyterPath(path: string): string { + assertNoJupyterPathTraversal(path) + return path +} + +/** + * Validates the `path` field of a `/api/tools/jupyter/proxy` request — an + * already-encoded relative path under `/api/` (e.g. + * `contents/notebooks%2Fa.ipynb?content=1`) that may carry a query string. + * Every tool already validates its own path segments before building this + * value, but the proxy route is a shared internal trust boundary reachable + * independent of any one tool's call site, so it re-validates rather than + * assuming the caller did. + * + * @throws {UnsafeJupyterPathError} when a segment of the path portion + * (everything before the first `?`) is `.` or `..`. + */ +export function assertSafeJupyterProxyPath(rawPath: string): void { + const [pathname] = rawPath.split('?') + assertNoJupyterPathTraversal(pathname) +} + +interface RawJupyterKernel { + id?: string + name?: string + last_activity?: string + execution_state?: string + connections?: number +} + +/** + * Maps a raw Jupyter kernel model (from Kernels/Sessions API responses) to + * Sim's shaped `JupyterKernel` output. + */ +export function mapJupyterKernel(raw: RawJupyterKernel): { + id: string + name: string + lastActivity: string | null + executionState: string | null + connections: number | null +} { + return { + id: raw.id ?? '', + name: raw.name ?? '', + lastActivity: raw.last_activity ?? null, + executionState: raw.execution_state ?? null, + connections: raw.connections ?? null, + } +} + +interface RawJupyterSession { + id?: string + path?: string + name?: string + type?: string + kernel?: RawJupyterKernel | null +} + +/** + * Maps a raw Jupyter session model to Sim's shaped `JupyterSession` output. + */ +export function mapJupyterSession(raw: RawJupyterSession): { + id: string + path: string + name: string + type: string + kernel: ReturnType | null +} { + return { + id: raw.id ?? '', + path: raw.path ?? '', + name: raw.name ?? '', + type: raw.type ?? '', + kernel: raw.kernel ? mapJupyterKernel(raw.kernel) : null, + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4fcda0f1cd4..4c2ce2bf0d9 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1970,6 +1970,24 @@ import { jsmTransitionRequestTool, jsmUpdateObjectTool, } from '@/tools/jsm' +import { + jupyterCopyContentTool, + jupyterCreateFileTool, + jupyterCreateSessionTool, + jupyterDeleteContentTool, + jupyterDeleteSessionTool, + jupyterGetContentTool, + jupyterInterruptKernelTool, + jupyterListContentsTool, + jupyterListKernelspecsTool, + jupyterListKernelsTool, + jupyterListSessionsTool, + jupyterRenameContentTool, + jupyterRestartKernelTool, + jupyterStartKernelTool, + jupyterStopKernelTool, + jupyterUploadFileTool, +} from '@/tools/jupyter' import { kalshiAmendOrderTool, kalshiAmendOrderV2Tool, @@ -5291,6 +5309,22 @@ export const tools: Record = { jsm_create_object: jsmCreateObjectTool, jsm_update_object: jsmUpdateObjectTool, jsm_delete_object: jsmDeleteObjectTool, + jupyter_list_contents: jupyterListContentsTool, + jupyter_get_content: jupyterGetContentTool, + jupyter_create_file: jupyterCreateFileTool, + jupyter_upload_file: jupyterUploadFileTool, + jupyter_rename_content: jupyterRenameContentTool, + jupyter_delete_content: jupyterDeleteContentTool, + jupyter_copy_content: jupyterCopyContentTool, + jupyter_list_kernels: jupyterListKernelsTool, + jupyter_start_kernel: jupyterStartKernelTool, + jupyter_stop_kernel: jupyterStopKernelTool, + jupyter_restart_kernel: jupyterRestartKernelTool, + jupyter_interrupt_kernel: jupyterInterruptKernelTool, + jupyter_list_kernelspecs: jupyterListKernelspecsTool, + jupyter_list_sessions: jupyterListSessionsTool, + jupyter_create_session: jupyterCreateSessionTool, + jupyter_delete_session: jupyterDeleteSessionTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1aa4dc0495d..23c532a1dea 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 919, - zodRoutes: 919, + totalRoutes: 921, + zodRoutes: 921, nonZodRoutes: 0, } as const