@simpligen/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SimpliGen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @simpligen/mcp
2
+
3
+ MCP server that lets an AI agent drive SimpliGen (local + cloud AI image/video generation). Requires the SimpliGen desktop app running and a pairing token (Settings -> Connect an agent).
4
+
5
+ ## Configure (Claude Code / Claude Desktop)
6
+
7
+ The easiest path is in the SimpliGen app: **Settings -> Connect an agent**, pick your client, and click Install (or copy the guided command). The manual config below is the fallback.
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "simpligen": {
13
+ "command": "npx",
14
+ "args": ["-y", "@simpligen/mcp"],
15
+ "env": { "SIMPLIGEN_TOKEN": "sg-agent-..." }
16
+ }
17
+ }
18
+ }
19
+ ```
20
+
21
+ ## Tools
22
+
23
+ `list_capabilities`, `get_status`, `list_projects`, `create_project`, `upload_file`, `generate`, `get_job`, `wait_for_result`, `list_jobs`, `cancel_job`, `prepare_preset`, `get_result_image`.
24
+
25
+ `get_result_image` returns a completed job's result image inline (a viewable image block) so the agent can display it in chat. Claude clients cap a tool result at ~1MB and only render base64 image blocks, so large renders are re-encoded to a **full-resolution WebP** preview (same dimensions; quality steps down, and only very large images are resized) that fits the cap. The uncompressed original file path is always included in the caption, so the agent keeps the full-quality artifact. Video results return the file path instead.
26
+
27
+ ## Env
28
+
29
+ - `SIMPLIGEN_TOKEN` (required) -- pairing token from the app.
30
+ - `SIMPLIGEN_API_PORT` (optional) -- override the control-API port (else read from the app's discovery file).
31
+ - `SIMPLIGEN_USERDATA_DIR` / `SIMPLIGEN_APP_NAME` (optional) -- locate the app's data dir (CN variant / custom).
32
+
33
+ ## Manual smoke (for maintainers)
34
+
35
+ With the app running and a token issued:
36
+
37
+ ```bash
38
+ SIMPLIGEN_TOKEN=<token> npx @modelcontextprotocol/inspector node src/bin.js
39
+ ```
40
+
41
+ Verify: tool list appears; call `get_status`; call `list_capabilities`; call `upload_file` with a PNG path and confirm a handle is returned; call `generate` with a local preset and confirm a `jobId`; call `wait_for_result` and confirm a `resultPath`; call `get_result_image` and confirm the image renders inline.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@simpligen/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SimpliGen -- drive local/cloud AI image & video generation from an agent.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://simpligen.io",
8
+ "repository": { "type": "git", "url": "git+https://github.com/RaynoldVanHeyningen/SimpliGen.git", "directory": "packages/mcp-server" },
9
+ "bin": { "simpligen-mcp": "src/bin.js" },
10
+ "main": "src/index.js",
11
+ "files": ["src", "README.md", "LICENSE"],
12
+ "engines": { "node": ">=18" },
13
+ "publishConfig": { "access": "public" },
14
+ "scripts": {
15
+ "test": "node --test src/*.test.js",
16
+ "prepublishOnly": "node --test src/*.test.js"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1",
20
+ "sharp": "^0.35.1",
21
+ "zod": "^3"
22
+ }
23
+ }
package/src/bin.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // packages/mcp-server/src/bin.js
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ControlApiClient } from './client.js';
5
+ import { createMcpServer } from './server.js';
6
+
7
+ async function main() {
8
+ const client = new ControlApiClient(); // resolves port (discovery file) + token (SIMPLIGEN_TOKEN)
9
+ const server = createMcpServer(client);
10
+ await server.connect(new StdioServerTransport());
11
+ }
12
+
13
+ main().catch((err) => { console.error('[simpligen-mcp] fatal:', err?.message || err); process.exit(1); });
package/src/client.js ADDED
@@ -0,0 +1,65 @@
1
+ // packages/mcp-server/src/client.js
2
+ // Thin client over the SimpliGen local control API. Resolves base URL + token,
3
+ // does authed fetch calls, throws a structured error on non-2xx.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { resolveUserDataDir, readDiscoveryPort } from './userdata.js';
7
+
8
+ export class ControlApiError extends Error {
9
+ constructor(status, errorCode, message) {
10
+ super(message || errorCode || `HTTP ${status}`);
11
+ this.name = 'ControlApiError';
12
+ this.status = status;
13
+ this.errorCode = errorCode || null;
14
+ }
15
+ }
16
+
17
+ export class ControlApiClient {
18
+ // opts: { baseUrl?, token?, fetchImpl?, env? }. Falls back to discovery file + env.
19
+ constructor(opts = {}) {
20
+ const env = opts.env || process.env;
21
+ this.token = opts.token || env.SIMPLIGEN_TOKEN || null;
22
+ this.fetch = opts.fetchImpl || globalThis.fetch;
23
+ if (opts.baseUrl) {
24
+ this.baseUrl = opts.baseUrl;
25
+ } else {
26
+ const port = env.SIMPLIGEN_API_PORT ? Number(env.SIMPLIGEN_API_PORT) : readDiscoveryPort(resolveUserDataDir(env));
27
+ this.baseUrl = port ? `http://127.0.0.1:${port}` : null;
28
+ }
29
+ }
30
+
31
+ async #req(method, p, body) {
32
+ if (!this.baseUrl) throw new ControlApiError(0, 'app_not_running', 'SimpliGen is not running (no control-API port found). Start the app.');
33
+ if (!this.token) throw new ControlApiError(0, 'missing_token', 'No pairing token. Set SIMPLIGEN_TOKEN (Settings -> Connect an agent).');
34
+ const res = await this.fetch(`${this.baseUrl}${p}`, {
35
+ method,
36
+ headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
37
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
38
+ });
39
+ let json = {};
40
+ try { json = await res.json(); } catch { /* empty body */ }
41
+ if (!res.ok) throw new ControlApiError(res.status, json.errorCode, json.message);
42
+ return json;
43
+ }
44
+
45
+ getCapabilities() { return this.#req('GET', '/capabilities'); }
46
+ getStatus() { return this.#req('GET', '/status'); }
47
+ listProjects() { return this.#req('GET', '/projects'); }
48
+ createProject(name) { return this.#req('POST', '/projects', { name }); }
49
+ generate(args) { return this.#req('POST', '/generate', args); }
50
+ getJob(id) { return this.#req('GET', `/jobs/${encodeURIComponent(id)}`); }
51
+ listJobs() { return this.#req('GET', '/jobs'); }
52
+ cancelJob(id) { return this.#req('POST', `/jobs/${encodeURIComponent(id)}/cancel`, {}); }
53
+ preparePreset(args) { return this.#req('POST', '/presets/prepare', args); }
54
+ getActiveDownloads() { return this.#req('GET', '/presets/prepare'); }
55
+
56
+ async uploadFile(filePath) {
57
+ let buf;
58
+ try {
59
+ buf = fs.readFileSync(filePath);
60
+ } catch (e) {
61
+ throw new ControlApiError(0, 'file_not_found', `Cannot read file to upload: ${filePath} (${e.code || e.message}).`);
62
+ }
63
+ return this.#req('POST', '/uploads', { filename: path.basename(filePath), dataBase64: buf.toString('base64') });
64
+ }
65
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // packages/mcp-server/src/index.js
2
+ export { ControlApiClient, ControlApiError } from './client.js';
3
+ export { createMcpServer } from './server.js';
4
+ export { registerTools, TOOL_NAMES } from './tools.js';
5
+ export { buildResultImageContent } from './resultImage.js';
6
+ export { resolveUserDataDir, readDiscoveryPort } from './userdata.js';
@@ -0,0 +1,121 @@
1
+ // packages/mcp-server/src/resultImage.js
2
+ // Build MCP content for a completed job's result image so an agent can display
3
+ // it inline. Claude clients only render base64 `image` blocks and cap a tool
4
+ // result at ~1MB, so a full-resolution PNG (often >1MB once base64-inflated)
5
+ // will not display. We therefore:
6
+ // - inline the ORIGINAL file untouched when it already fits the budget, else
7
+ // - re-encode a FULL-RESOLUTION WebP preview (no dimension change) at high
8
+ // quality, stepping quality down -- and only resizing as a last resort for
9
+ // enormous renders -- until it fits.
10
+ // The original full-quality file is ALWAYS reported via its path in the caption,
11
+ // so the agent keeps the uncompressed artifact; the WebP is a display preview.
12
+ // `readFile` and `encodeWebp` are injected for tests (no native sharp needed).
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ // Inline content types Claude renders directly (so we pass the original through
17
+ // when it already fits, no re-encode).
18
+ const DIRECT_MIME = {
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.jpeg': 'image/jpeg',
22
+ '.webp': 'image/webp',
23
+ '.gif': 'image/gif',
24
+ };
25
+
26
+ // Raw-byte budget for the inline payload. base64 inflates ~4/3, so 700KB raw
27
+ // -> ~933KB base64, under the client's 1MB tool-result cap with envelope room.
28
+ export const DEFAULT_BUDGET_BYTES = 700 * 1024;
29
+
30
+ // Tried in order; first encoding under budget wins. Quality drops first to keep
31
+ // full resolution; resizing is the last resort for very large renders.
32
+ const ENCODE_PLAN = [
33
+ { quality: 90 },
34
+ { quality: 82 },
35
+ { quality: 72 },
36
+ { quality: 80, maxDimension: 2048 },
37
+ { quality: 78, maxDimension: 1280 },
38
+ { quality: 75, maxDimension: 1024 },
39
+ ];
40
+
41
+ let _sharp;
42
+ async function defaultEncodeWebp(input, { quality, maxDimension } = {}) {
43
+ if (!_sharp) _sharp = (await import('sharp')).default;
44
+ let img = _sharp(input, { failOn: 'none', animated: false });
45
+ if (maxDimension) {
46
+ img = img.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true });
47
+ }
48
+ return img.webp({ quality, effort: 4 }).toBuffer();
49
+ }
50
+
51
+ const note = (text) => ({ content: [{ type: 'text', text }] });
52
+ const imageBlock = (buf, mimeType, caption) => ({
53
+ content: [
54
+ { type: 'image', data: buf.toString('base64'), mimeType },
55
+ { type: 'text', text: caption },
56
+ ],
57
+ });
58
+
59
+ // job: agent-facing job shape ({ jobId, status, resultPath, mediaType, ... }).
60
+ // opts: { readFile?, encodeWebp?, budgetBytes? }.
61
+ export async function buildResultImageContent(job, opts = {}) {
62
+ const readFile = opts.readFile || fs.readFileSync;
63
+ const encodeWebp = opts.encodeWebp || defaultEncodeWebp;
64
+ const budget = opts.budgetBytes || DEFAULT_BUDGET_BYTES;
65
+
66
+ if (!job) return note('No such job.');
67
+ if (job.status !== 'completed') {
68
+ return note(`Job ${job.jobId || ''} is "${job.status || 'unknown'}"; no result image yet.`);
69
+ }
70
+ const p = job.resultPath;
71
+ if (!p) return note('Job completed but has no result path.');
72
+ if (job.mediaType === 'video') {
73
+ return note(`Result is a video, which cannot be displayed inline. It is at: ${p}`);
74
+ }
75
+
76
+ const ext = path.extname(p).toLowerCase();
77
+ const directMime = DIRECT_MIME[ext];
78
+ if (!directMime) {
79
+ return note(`Result is not an inlineable image (${ext || 'no extension'}). It is at: ${p}`);
80
+ }
81
+
82
+ let original;
83
+ try {
84
+ original = readFile(p);
85
+ } catch (e) {
86
+ return note(`Could not read the result file (${e.code || e.message}). It is at: ${p}`);
87
+ }
88
+
89
+ // Already small enough: inline the original untouched (lossless).
90
+ if (original.length <= budget) {
91
+ return imageBlock(original, directMime, `Result image (${directMime}, full file): ${p}`);
92
+ }
93
+
94
+ // Too big to inline as-is: produce a full-resolution WebP preview that fits.
95
+ // The original full-quality file stays on disk and is named in the caption.
96
+ let smallest = null;
97
+ let smallestCfg = null;
98
+ for (const cfg of ENCODE_PLAN) {
99
+ let webp;
100
+ try {
101
+ webp = await encodeWebp(original, cfg);
102
+ } catch (e) {
103
+ return note(`Could not encode a preview (${e.message}). Full-quality original is at: ${p}`);
104
+ }
105
+ if (!smallest || webp.length < smallest.length) {
106
+ smallest = webp;
107
+ smallestCfg = cfg;
108
+ }
109
+ if (webp.length <= budget) {
110
+ const dim = cfg.maxDimension ? `, resized to <=${cfg.maxDimension}px for display` : ', full resolution';
111
+ return imageBlock(webp, 'image/webp', `Inline preview (WebP q${cfg.quality}${dim}). Full-quality original (uncompressed) is at: ${p}`);
112
+ }
113
+ }
114
+
115
+ // Even the most aggressive config exceeded the budget (extreme image): give
116
+ // the smallest preview we made, flagged, plus the original path.
117
+ if (smallest) {
118
+ return imageBlock(smallest, 'image/webp', `Inline preview (WebP q${smallestCfg.quality}, best effort -- may exceed the client size cap). Full-quality original is at: ${p}`);
119
+ }
120
+ return note(`Image too large to preview inline. Open it at: ${p}`);
121
+ }
package/src/server.js ADDED
@@ -0,0 +1,9 @@
1
+ // packages/mcp-server/src/server.js
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { registerTools } from './tools.js';
4
+
5
+ export function createMcpServer(client) {
6
+ const server = new McpServer({ name: 'simpligen', version: '0.1.0' });
7
+ registerTools(server, client);
8
+ return server;
9
+ }
package/src/tools.js ADDED
@@ -0,0 +1,96 @@
1
+ // packages/mcp-server/src/tools.js
2
+ // Registers SimpliGen MCP tools on an McpServer-shaped object. Each tool calls a
3
+ // ControlApiClient method and returns the JSON result as text content; client
4
+ // errors become an isError tool result (so the agent sees the errorCode/message).
5
+ import { z } from 'zod';
6
+ import { buildResultImageContent } from './resultImage.js';
7
+
8
+ export const TOOL_NAMES = [
9
+ 'list_capabilities', 'get_status', 'list_projects', 'create_project',
10
+ 'upload_file', 'generate', 'get_job', 'wait_for_result', 'list_jobs',
11
+ 'cancel_job', 'prepare_preset', 'get_result_image',
12
+ ];
13
+
14
+ const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
15
+ const fail = (err) => ({ isError: true, content: [{ type: 'text', text: `${err.errorCode ? err.errorCode + ': ' : ''}${err.message || String(err)}` }] });
16
+ const wrap = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } };
17
+
18
+ export function registerTools(server, client) {
19
+ server.registerTool('list_capabilities',
20
+ { title: 'List capabilities', description: 'List every SimpliGen preset the agent can generate with (image/video), its inputs/options, whether it is installed locally (localReady) and cloud-eligible + credit cost.', inputSchema: {} },
21
+ wrap(() => client.getCapabilities()));
22
+
23
+ server.registerTool('get_status',
24
+ { title: 'Get status', description: 'Engine running state, whether cloud is connected, credit balance, and the active project.', inputSchema: {} },
25
+ wrap(() => client.getStatus()));
26
+
27
+ server.registerTool('list_projects',
28
+ { title: 'List projects', description: 'List projects (output containers).', inputSchema: {} },
29
+ wrap(() => client.listProjects()));
30
+
31
+ server.registerTool('create_project',
32
+ { title: 'Create project', description: 'Create a new project (output container).', inputSchema: { name: z.string().describe('Project name') } },
33
+ wrap(({ name }) => client.createProject(name)));
34
+
35
+ server.registerTool('upload_file',
36
+ { title: 'Upload a file', description: 'Upload a local image/video file and get an opaque handle to pass as image/referenceImages/video in generate.', inputSchema: { path: z.string().describe('Absolute local file path') } },
37
+ wrap(({ path }) => client.uploadFile(path)));
38
+
39
+ server.registerTool('generate',
40
+ {
41
+ title: 'Generate', description: 'Start an image or video generation. Inputs (image/referenceImages/video) are handles from upload_file. backend: local | cloud | auto. Returns a jobId; poll with wait_for_result or get_job.',
42
+ inputSchema: {
43
+ presetId: z.string(), mediaType: z.enum(['image', 'video']), prompt: z.string(),
44
+ packId: z.string().optional(), backend: z.enum(['local', 'cloud', 'auto']).optional(),
45
+ negativePrompt: z.string().optional(), project: z.string().optional(),
46
+ image: z.string().optional().describe('upload_file handle'),
47
+ referenceImages: z.array(z.string()).optional().describe('upload_file handles'),
48
+ video: z.string().optional().describe('upload_file handle (driving video)'),
49
+ options: z.object({ resolution: z.string().optional(), durationSeconds: z.number().optional(), steps: z.number().optional(), cfg: z.number().optional(), seed: z.number().optional() }).optional(),
50
+ },
51
+ },
52
+ wrap((args) => client.generate(args)));
53
+
54
+ server.registerTool('get_job',
55
+ { title: 'Get job', description: 'Status + result of a generation job. resultPath is the local file when completed.', inputSchema: { jobId: z.string() } },
56
+ wrap(({ jobId }) => client.getJob(jobId)));
57
+
58
+ server.registerTool('list_jobs',
59
+ { title: 'List jobs', description: 'Recent generation jobs.', inputSchema: {} },
60
+ wrap(() => client.listJobs()));
61
+
62
+ server.registerTool('cancel_job',
63
+ { title: 'Cancel job', description: 'Cancel a queued/running job.', inputSchema: { jobId: z.string() } },
64
+ wrap(({ jobId }) => client.cancelJob(jobId)));
65
+
66
+ server.registerTool('prepare_preset',
67
+ { title: 'Prepare preset', description: 'Start downloading a local preset\'s models (so it becomes localReady). Poll list_capabilities for localReady.', inputSchema: { presetId: z.string(), mediaType: z.enum(['image', 'video']), packId: z.string().optional() } },
68
+ wrap((args) => client.preparePreset(args)));
69
+
70
+ // Convenience: poll get_job until terminal or timeout (no extra endpoint).
71
+ server.registerTool('wait_for_result',
72
+ { title: 'Wait for result', description: 'Block until a job completes or fails (or the timeout). Returns the final job (resultPath when completed).', inputSchema: { jobId: z.string(), timeoutSeconds: z.number().optional(), pollMs: z.number().optional() } },
73
+ wrap(async ({ jobId, timeoutSeconds = 300, pollMs = 2000 }) => {
74
+ const deadline = Date.now() + timeoutSeconds * 1000;
75
+ // eslint-disable-next-line no-constant-condition
76
+ while (true) {
77
+ const { job } = await client.getJob(jobId);
78
+ if (!job || job.status === 'completed' || job.status === 'failed') return job;
79
+ if (Date.now() >= deadline) return { ...job, note: 'timeout_still_running' };
80
+ await new Promise((r) => setTimeout(r, pollMs));
81
+ }
82
+ }));
83
+
84
+ // Return a completed job's result image inline (an MCP image block) so the
85
+ // agent can actually display it. Images only; video/unreadable -> path note.
86
+ server.registerTool('get_result_image',
87
+ { title: 'Get result image', description: 'Return a completed job\'s result image inline as a viewable image so it can be displayed in chat. Large images are re-encoded to a full-resolution WebP preview that fits the client size cap; the uncompressed original file path is always included in the caption. Video results return the file path instead. Call after the job is completed.', inputSchema: { jobId: z.string(), maxBytes: z.number().optional().describe('Inline byte budget before re-encoding/resizing (default ~700KB raw)') } },
88
+ async ({ jobId, maxBytes }) => {
89
+ try {
90
+ const { job } = await client.getJob(jobId);
91
+ return await buildResultImageContent(job, { budgetBytes: maxBytes });
92
+ } catch (e) {
93
+ return fail(e);
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,33 @@
1
+ // packages/mcp-server/src/userdata.js
2
+ // Resolve the SimpliGen App's userData dir (this runs as a standalone Node
3
+ // process, not Electron) and read the control-API discovery file for the port.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ // env defaults to process.env, platform to process.platform; passed in for tests.
9
+ export function resolveUserDataDir(env = process.env, platform = process.platform) {
10
+ if (env.SIMPLIGEN_USERDATA_DIR) return env.SIMPLIGEN_USERDATA_DIR;
11
+ const name = env.SIMPLIGEN_APP_NAME || 'simpligen';
12
+ if (platform === 'win32') {
13
+ const appData = env.APPDATA || path.join(env.USERPROFILE || os.homedir(), 'AppData', 'Roaming');
14
+ return path.join(appData, name);
15
+ }
16
+ if (platform === 'darwin') {
17
+ // Electron derives userData from app.getName(), which returns the App's
18
+ // package.json `name` ("simpligen", lowercase) -- there is no productName
19
+ // or app.setName() at runtime, so the dir is lowercase on macOS too.
20
+ return path.join(env.HOME || os.homedir(), 'Library', 'Application Support', name);
21
+ }
22
+ return path.join(env.HOME || os.homedir(), '.config', name);
23
+ }
24
+
25
+ export function readDiscoveryPort(userDataDir) {
26
+ try {
27
+ const raw = fs.readFileSync(path.join(userDataDir, 'agent-api.json'), 'utf8');
28
+ const port = JSON.parse(raw).port;
29
+ return Number.isInteger(port) ? port : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }