@testchimp/cli 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/dist/index.js ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TestChimp MCP server — calls TestChimp /api/mcp/* with TestChimp-Api-Key only.
4
+ * Env: TESTCHIMP_BACKEND_URL (optional), TESTCHIMP_API_KEY (required).
5
+ * Includes SmartTests coverage, plan authoring, EaaS, and TrueCoverage analytics endpoints.
6
+ */
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { runProvisionEphemeralEnvironmentAndWait } from "./ephemeralProvisionWait.js";
11
+ const DEFAULT_BACKEND = "https://featureservice.testchimp.io";
12
+ function getBackendUrl() {
13
+ const raw = process.env.TESTCHIMP_BACKEND_URL?.trim();
14
+ if (!raw)
15
+ return DEFAULT_BACKEND;
16
+ return raw.replace(/\/$/, "");
17
+ }
18
+ function requireApiKey() {
19
+ const k = process.env.TESTCHIMP_API_KEY?.trim();
20
+ if (!k) {
21
+ throw new Error("TESTCHIMP_API_KEY is required. Set it in <project>/.cursor/mcp.json env (project-level MCP config), not IDE-wide config.");
22
+ }
23
+ return k;
24
+ }
25
+ async function postMcp(path, body) {
26
+ const apiKey = requireApiKey();
27
+ const url = `${getBackendUrl()}${path}`;
28
+ const res = await fetch(url, {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ "TestChimp-Api-Key": apiKey,
33
+ },
34
+ body: JSON.stringify(body ?? {}),
35
+ });
36
+ const text = await res.text();
37
+ if (!res.ok) {
38
+ throw new Error(`TestChimp API ${res.status} ${res.statusText}: ${text}`);
39
+ }
40
+ return text;
41
+ }
42
+ const scopeSchema = z
43
+ .object({
44
+ /** Relative paths from platform tests root, e.g. "e2e/checkout.spec.ts" */
45
+ filePaths: z.array(z.string()).optional(),
46
+ folderPath: z.union([z.array(z.string()), z.string()]).optional(),
47
+ })
48
+ .optional();
49
+ const listCoverageInput = z.object({
50
+ release: z.string().optional(),
51
+ environment: z.string().optional(),
52
+ scope: scopeSchema,
53
+ includeNonCoveredUserStories: z.boolean().optional(),
54
+ includeNonCoveredTestScenarios: z.boolean().optional(),
55
+ /** Git branch name (e.g. "main"). Prefer over internal branch ids. */
56
+ branchName: z.string().optional(),
57
+ });
58
+ const listExecutionInput = z.object({
59
+ release: z.string().optional(),
60
+ environment: z.string().optional(),
61
+ scope: scopeSchema,
62
+ branchName: z.string().optional(),
63
+ });
64
+ /** Platform path to the new markdown file, e.g. plans/stories/auth/login-flow.md */
65
+ const createUserStoryInput = z.object({
66
+ platformFilePath: z.string().min(1),
67
+ title: z.string().min(1),
68
+ });
69
+ const createTestScenarioInput = z.object({
70
+ platformFilePath: z.string().min(1),
71
+ title: z.string().min(1),
72
+ /** Parent story ordinal (the number n in US-n). */
73
+ userStoryOrdinalId: z.coerce.number().int().positive(),
74
+ });
75
+ const updatePlanMarkdownInput = z.object({
76
+ /** Full markdown including YAML frontmatter and body (as written under the repo plans root). */
77
+ content: z.string().min(1),
78
+ });
79
+ const emptyInput = z.object({});
80
+ const getBranchSpecificEndpointConfigInput = z.object({
81
+ /** Git branch name (e.g. PR head). Required to resolve template or per-branch override. */
82
+ branchName: z.string().optional(),
83
+ });
84
+ /** Proto-shaped JSON for TrueCoverage (e.g. list_events: baseExecutionScope, comparisonExecutionScope). */
85
+ const truecoverageJsonInput = z.record(z.string(), z.unknown());
86
+ const eventMetadataKeysInput = z.object({
87
+ eventTitle: z.string().min(1),
88
+ });
89
+ const provisionEphemeralInput = z.object({
90
+ /** Git branch to deploy; omit to use the repo default branch. */
91
+ branchName: z.string().optional(),
92
+ });
93
+ const bnsEnvironmentIdInput = z.object({
94
+ /** BunnyShell environment id returned from provision_ephemeral_environment. */
95
+ bnsEnvironmentId: z.string().min(1),
96
+ });
97
+ const provisionEphemeralWaitInput = z.object({
98
+ /** Git branch to deploy; omit to use the repo default branch. */
99
+ branchName: z.string().optional(),
100
+ /** Seconds between status polls (clamped 30–120). Default 60. */
101
+ pollIntervalSeconds: z.number().optional(),
102
+ /** Max minutes to wait for deployed + URLs (clamped 5–45). Default 25. */
103
+ maxWaitMinutes: z.number().optional(),
104
+ });
105
+ const listBunnyshellEnvironmentEventsInput = z.object({
106
+ bnsEnvironmentId: z.string().min(1),
107
+ /** BunnyShell event type filter (e.g. env_deploy). */
108
+ eventType: z.string().optional(),
109
+ /** BunnyShell event status: new | in_progress | success | fail */
110
+ eventStatus: z.string().optional(),
111
+ page: z.number().int().positive().optional(),
112
+ });
113
+ const listBunnyshellWorkflowJobsInput = z.object({
114
+ bnsEnvironmentId: z.string().min(1),
115
+ page: z.number().int().positive().optional(),
116
+ });
117
+ const getBunnyshellWorkflowJobLogsInput = z.object({
118
+ bnsEnvironmentId: z.string().min(1),
119
+ workflowJobId: z.string().min(1),
120
+ });
121
+ function textResult(json) {
122
+ return {
123
+ content: [{ type: "text", text: json }],
124
+ };
125
+ }
126
+ function normalizeScope(scope) {
127
+ let folderPath;
128
+ if (Array.isArray(scope.folderPath)) {
129
+ folderPath = scope.folderPath;
130
+ }
131
+ else if (typeof scope.folderPath === "string" && scope.folderPath.trim() !== "") {
132
+ folderPath = scope.folderPath
133
+ .split("/")
134
+ .map((seg) => seg.trim())
135
+ .filter(Boolean);
136
+ }
137
+ const out = {};
138
+ if (scope.filePaths?.length)
139
+ out.filePaths = scope.filePaths;
140
+ if (folderPath)
141
+ out.folderPath = folderPath;
142
+ return out;
143
+ }
144
+ async function main() {
145
+ const server = new McpServer({ name: "testchimp-mcp", version: "0.0.8" }, { capabilities: { tools: {}, logging: {} } });
146
+ server.registerTool("get_requirement_coverage", {
147
+ description: "Fetch requirement (scenario) coverage under an optional platform-rooted folder scope (tests/... or plans/...). " +
148
+ "Use branchName (Git branch) and scope.filePaths (paths under platform tests root) rather than internal ids. " +
149
+ "Maps to TestChimp list_requirement_coverage API.",
150
+ inputSchema: listCoverageInput,
151
+ }, async (args) => {
152
+ const body = {};
153
+ if (args.release != null)
154
+ body.release = args.release;
155
+ if (args.environment != null)
156
+ body.environment = args.environment;
157
+ if (args.scope != null)
158
+ body.scope = normalizeScope(args.scope);
159
+ if (args.includeNonCoveredUserStories != null) {
160
+ body.includeNonCoveredUserStories = args.includeNonCoveredUserStories;
161
+ }
162
+ if (args.includeNonCoveredTestScenarios != null) {
163
+ body.includeNonCoveredTestScenarios = args.includeNonCoveredTestScenarios;
164
+ }
165
+ if (args.branchName != null && args.branchName.trim() !== "") {
166
+ body.branchName = args.branchName.trim();
167
+ }
168
+ const json = await postMcp("/api/mcp/list_requirement_coverage", body);
169
+ return textResult(json);
170
+ });
171
+ server.registerTool("get_execution_history", {
172
+ description: "Fetch SmartTest execution history for an optional platform-rooted folder scope (tests/... or plans/...). " +
173
+ "Use branchName and scope.filePaths as for coverage.",
174
+ inputSchema: listExecutionInput,
175
+ }, async (args) => {
176
+ const body = {};
177
+ if (args.release != null)
178
+ body.release = args.release;
179
+ if (args.environment != null)
180
+ body.environment = args.environment;
181
+ if (args.scope != null)
182
+ body.scope = normalizeScope(args.scope);
183
+ if (args.branchName != null && args.branchName.trim() !== "") {
184
+ body.branchName = args.branchName.trim();
185
+ }
186
+ const json = await postMcp("/api/mcp/list_execution_history", body);
187
+ return textResult(json);
188
+ });
189
+ server.registerTool("create_user_story", {
190
+ description: "Create a user story on the TestChimp project and its plan file stub. " +
191
+ "Always call this before writing a new story markdown file; use the returned ordinalId as US-<ordinalId> in frontmatter. " +
192
+ "platformFilePath must be under plans/stories/ and end with .md.",
193
+ inputSchema: createUserStoryInput,
194
+ }, async (args) => {
195
+ const json = await postMcp("/api/mcp/create_user_story", {
196
+ platformFilePath: args.platformFilePath,
197
+ title: args.title,
198
+ });
199
+ return textResult(json);
200
+ });
201
+ server.registerTool("create_test_scenario", {
202
+ description: "Create a test scenario linked to a user story. Call after the parent story exists. " +
203
+ "platformFilePath must be under plans/scenarios/ and end with .md. " +
204
+ "userStoryOrdinalId is the numeric part of the parent US-<n> id.",
205
+ inputSchema: createTestScenarioInput,
206
+ }, async (args) => {
207
+ const json = await postMcp("/api/mcp/create_test_scenario", {
208
+ platformFilePath: args.platformFilePath,
209
+ title: args.title,
210
+ userStoryOrdinalId: args.userStoryOrdinalId,
211
+ });
212
+ return textResult(json);
213
+ });
214
+ server.registerTool("update_user_story", {
215
+ description: "Sync a user story markdown file to the platform after local edits. " +
216
+ "Parses frontmatter (id: US-..., title, priority, status) and updates the linked support file and entity.",
217
+ inputSchema: updatePlanMarkdownInput,
218
+ }, async (args) => {
219
+ const json = await postMcp("/api/mcp/update_user_story", { content: args.content });
220
+ return textResult(json);
221
+ });
222
+ server.registerTool("update_test_scenario", {
223
+ description: "Sync a test scenario markdown file to the platform after local edits. " +
224
+ "Parses frontmatter (id: TS-..., story: US-..., title, priority, status) and updates linking if story changes.",
225
+ inputSchema: updatePlanMarkdownInput,
226
+ }, async (args) => {
227
+ const json = await postMcp("/api/mcp/update_test_scenario", { content: args.content });
228
+ return textResult(json);
229
+ });
230
+ server.registerTool("get_eaas_config", {
231
+ description: "Return the project's BunnyShell (Environment-as-a-Service) settings: ymlRepoPath and bunnyshellProjectName. " +
232
+ "Secrets (API token) are never returned. Response is {} when EaaS is not configured or has no public fields.",
233
+ inputSchema: emptyInput,
234
+ }, async () => {
235
+ const json = await postMcp("/api/mcp/get_eaas_config", {});
236
+ return textResult(json);
237
+ });
238
+ server.registerTool("get_branch_specific_endpoint_config", {
239
+ description: "Resolve BASE_URL for a Git branch from TestChimp Branch Management (URL template and per-branch overrides). " +
240
+ "Prefer this when BunnyShell EaaS is not configured and the project uses bespoke PR preview URLs. " +
241
+ "Pass branchName (e.g. the PR branch). Response includes baseUrl and resolution: override | template | none.",
242
+ inputSchema: getBranchSpecificEndpointConfigInput,
243
+ }, async (args) => {
244
+ const body = {};
245
+ if (args.branchName != null && args.branchName.trim() !== "") {
246
+ body.branchName = args.branchName.trim();
247
+ }
248
+ const json = await postMcp("/api/mcp/get_branch_specific_endpoint_config", body);
249
+ return textResult(json);
250
+ });
251
+ server.registerTool("provision_ephemeral_environment_and_wait", {
252
+ description: "Preferred: provision a BunnyShell ephemeral environment for the current Git branch, then poll until the stack is deployed and component URLs are available (typically ~5–10 minutes). " +
253
+ "Returns one JSON with outcome success|failed|timeout, failure_phase (provision|deploy|wait), user-facing message, and component_urls_json on success. " +
254
+ "Requires BunnyShell + GitHub integration. If this tool is unavailable or the host aborts long calls, fall back to provision_ephemeral_environment + polling get_ephemeral_environment_status (~1/min, max ~25m).",
255
+ inputSchema: provisionEphemeralWaitInput,
256
+ }, async (args) => {
257
+ const json = await runProvisionEphemeralEnvironmentAndWait(postMcp, server, {
258
+ branchName: args.branchName,
259
+ pollIntervalSeconds: args.pollIntervalSeconds,
260
+ maxWaitMinutes: args.maxWaitMinutes,
261
+ });
262
+ return textResult(json);
263
+ });
264
+ server.registerTool("provision_ephemeral_environment", {
265
+ description: "Create a BunnyShell ephemeral environment (create + deploy trigger only). Prefer provision_ephemeral_environment_and_wait unless you must poll manually. " +
266
+ "Requires BunnyShell + GitHub integration. Use get_ephemeral_environment_status with bnsEnvironmentId to poll until deployed.",
267
+ inputSchema: provisionEphemeralInput,
268
+ }, async (args) => {
269
+ const body = {};
270
+ if (args.branchName != null && args.branchName.trim() !== "") {
271
+ body.branchName = args.branchName.trim();
272
+ }
273
+ const json = await postMcp("/api/mcp/provision_ephemeral_environment", body);
274
+ return textResult(json);
275
+ });
276
+ server.registerTool("get_ephemeral_environment_status", {
277
+ description: "Poll BunnyShell for environment status and component_urls_json. Used for manual fallback when provision_ephemeral_environment_and_wait is not available; prefer the wait tool for normal flows.",
278
+ inputSchema: bnsEnvironmentIdInput,
279
+ }, async (args) => {
280
+ const json = await postMcp("/api/mcp/get_ephemeral_environment_status", {
281
+ bnsEnvironmentId: args.bnsEnvironmentId,
282
+ });
283
+ return textResult(json);
284
+ });
285
+ server.registerTool("destroy_ephemeral_environment", {
286
+ description: "Delete a BunnyShell environment created for this project (bnsEnvironmentId from provision).",
287
+ inputSchema: bnsEnvironmentIdInput,
288
+ }, async (args) => {
289
+ const json = await postMcp("/api/mcp/destroy_ephemeral_environment", {
290
+ bnsEnvironmentId: args.bnsEnvironmentId,
291
+ });
292
+ return textResult(json);
293
+ });
294
+ server.registerTool("list_bunnyshell_environment_events", {
295
+ description: "Troubleshooting: list BunnyShell platform events for an environment (GET /v1/events). " +
296
+ "Use after a failed or stuck ephemeral deploy. The HTTP response body is the BunnyShell payload as returned by the API (no extra wrapping). " +
297
+ "Optional filters: eventType, eventStatus (new|in_progress|success|fail), page.",
298
+ inputSchema: listBunnyshellEnvironmentEventsInput,
299
+ }, async (args) => {
300
+ const body = { bnsEnvironmentId: args.bnsEnvironmentId };
301
+ if (args.eventType != null && args.eventType.trim() !== "")
302
+ body.eventType = args.eventType.trim();
303
+ if (args.eventStatus != null && args.eventStatus.trim() !== "")
304
+ body.eventStatus = args.eventStatus.trim();
305
+ if (args.page != null)
306
+ body.page = args.page;
307
+ const json = await postMcp("/api/mcp/list_bunnyshell_environment_events", body);
308
+ return textResult(json);
309
+ });
310
+ server.registerTool("list_bunnyshell_workflow_jobs", {
311
+ description: "Troubleshooting: list BunnyShell workflow jobs for an environment. Response body is the BunnyShell API payload as returned (no extra wrapping). " +
312
+ "Use to find workflowJobId for get_bunnyshell_workflow_job_logs.",
313
+ inputSchema: listBunnyshellWorkflowJobsInput,
314
+ }, async (args) => {
315
+ const body = { bnsEnvironmentId: args.bnsEnvironmentId };
316
+ if (args.page != null)
317
+ body.page = args.page;
318
+ const json = await postMcp("/api/mcp/list_bunnyshell_workflow_jobs", body);
319
+ return textResult(json);
320
+ });
321
+ server.registerTool("get_bunnyshell_workflow_job_logs", {
322
+ description: "Troubleshooting: fetch logs for a BunnyShell workflow job (deploy/build pipeline). " +
323
+ "Response body is the BunnyShell /v1/workflow_jobs/{id}/logs payload as returned (no truncation or wrapping).",
324
+ inputSchema: getBunnyshellWorkflowJobLogsInput,
325
+ }, async (args) => {
326
+ const json = await postMcp("/api/mcp/get_bunnyshell_workflow_job_logs", {
327
+ bnsEnvironmentId: args.bnsEnvironmentId,
328
+ workflowJobId: args.workflowJobId,
329
+ });
330
+ return textResult(json);
331
+ });
332
+ server.registerTool("list_rum_environments", {
333
+ description: "List distinct RUM environment tags seen for this project (for TrueCoverage scoping). " +
334
+ "Maps to POST /api/mcp/list_rum_environments.",
335
+ inputSchema: emptyInput,
336
+ }, async () => {
337
+ const json = await postMcp("/api/mcp/list_rum_environments", {});
338
+ return textResult(json);
339
+ });
340
+ server.registerTool("get_truecoverage_events", {
341
+ description: "TrueCoverage event funnel summaries for the given execution scopes (same JSON body as platform list_events). " +
342
+ "Maps to POST /api/mcp/truecoverage_list_events.",
343
+ inputSchema: truecoverageJsonInput,
344
+ }, async (args) => {
345
+ const json = await postMcp("/api/mcp/truecoverage_list_events", args ?? {});
346
+ return textResult(json);
347
+ });
348
+ server.registerTool("get_truecoverage_event_details", {
349
+ description: "TrueCoverage drill-down for one event title (GetEventDetailsRequest JSON). " +
350
+ "Maps to POST /api/mcp/truecoverage_event_details.",
351
+ inputSchema: truecoverageJsonInput,
352
+ }, async (args) => {
353
+ const json = await postMcp("/api/mcp/truecoverage_event_details", args ?? {});
354
+ return textResult(json);
355
+ });
356
+ server.registerTool("get_truecoverage_child_event_tree", {
357
+ description: "TrueCoverage next-event tree for an event (ListChildEventTreeRequest JSON). " +
358
+ "Maps to POST /api/mcp/truecoverage_list_child_event_tree.",
359
+ inputSchema: truecoverageJsonInput,
360
+ }, async (args) => {
361
+ const json = await postMcp("/api/mcp/truecoverage_list_child_event_tree", args ?? {});
362
+ return textResult(json);
363
+ });
364
+ server.registerTool("get_truecoverage_event_transition", {
365
+ description: "TrueCoverage detailed transition summary between events (GetDetailedEventTransitionSummaryRequest JSON). " +
366
+ "Maps to POST /api/mcp/truecoverage_detailed_event_transition.",
367
+ inputSchema: truecoverageJsonInput,
368
+ }, async (args) => {
369
+ const json = await postMcp("/api/mcp/truecoverage_detailed_event_transition", args ?? {});
370
+ return textResult(json);
371
+ });
372
+ server.registerTool("get_truecoverage_event_time_series", {
373
+ description: "TrueCoverage time series for sessions or metrics (EventTimeSeriesRequest JSON). " +
374
+ "Maps to POST /api/mcp/truecoverage_event_time_series.",
375
+ inputSchema: truecoverageJsonInput,
376
+ }, async (args) => {
377
+ const json = await postMcp("/api/mcp/truecoverage_event_time_series", args ?? {});
378
+ return textResult(json);
379
+ });
380
+ server.registerTool("get_truecoverage_session_metadata_keys", {
381
+ description: "List session-level metadata keys observed for TrueCoverage. Maps to POST /api/mcp/truecoverage_session_metadata_keys.",
382
+ inputSchema: emptyInput,
383
+ }, async () => {
384
+ const json = await postMcp("/api/mcp/truecoverage_session_metadata_keys", {});
385
+ return textResult(json);
386
+ });
387
+ server.registerTool("get_truecoverage_event_metadata_keys", {
388
+ description: "List metadata keys for a given event title. Maps to POST /api/mcp/truecoverage_event_metadata_keys.",
389
+ inputSchema: eventMetadataKeysInput,
390
+ }, async (args) => {
391
+ const json = await postMcp("/api/mcp/truecoverage_event_metadata_keys", {
392
+ eventTitle: args.eventTitle,
393
+ });
394
+ return textResult(json);
395
+ });
396
+ const transport = new StdioServerTransport();
397
+ await server.connect(transport);
398
+ }
399
+ main().catch((err) => {
400
+ console.error(err);
401
+ process.exit(1);
402
+ });
@@ -0,0 +1 @@
1
+ export declare function runMcpServer(): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { postMcp } from "../core/client.js";
4
+ import { TOOL_DEFINITIONS, runTool } from "../core/tools.js";
5
+ const PACKAGE_VERSION = "0.1.0";
6
+ function textResult(json) {
7
+ return {
8
+ content: [{ type: "text", text: json }],
9
+ };
10
+ }
11
+ export async function runMcpServer() {
12
+ const server = new McpServer({ name: "testchimp", version: PACKAGE_VERSION }, { capabilities: { tools: {}, logging: {} } });
13
+ for (const def of TOOL_DEFINITIONS) {
14
+ server.registerTool(def.kebab, {
15
+ description: def.description,
16
+ inputSchema: def.inputSchema,
17
+ }, async (args) => {
18
+ const onProgress = def.kebab === "provision-ephemeral-environment-and-wait"
19
+ ? async (message) => {
20
+ try {
21
+ await server.sendLoggingMessage({ level: "info", data: message });
22
+ }
23
+ catch {
24
+ /* ignore */
25
+ }
26
+ }
27
+ : undefined;
28
+ const json = await runTool(def.kebab, args ?? {}, {
29
+ postMcp,
30
+ onProgress,
31
+ });
32
+ return textResult(json);
33
+ });
34
+ }
35
+ const transport = new StdioServerTransport();
36
+ await server.connect(transport);
37
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@testchimp/cli",
3
+ "version": "0.1.0",
4
+ "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage (calls /api/mcp/*)",
5
+ "type": "module",
6
+ "main": "dist/bin/testchimp.js",
7
+ "types": "dist/bin/testchimp.d.ts",
8
+ "bin": {
9
+ "testchimp": "dist/bin/testchimp.js",
10
+ "testchimp-mcp": "dist/bin/legacy-mcp.js"
11
+ },
12
+ "files": [
13
+ "dist/"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "watch": "tsc --watch",
18
+ "start": "node dist/bin/testchimp.js",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.29.0",
26
+ "commander": "^12.1.0",
27
+ "zod": "^4.3.6"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.6.0",
31
+ "typescript": "^6.0.2"
32
+ },
33
+ "keywords": [
34
+ "testchimp",
35
+ "mcp",
36
+ "cli",
37
+ "model-context-protocol"
38
+ ],
39
+ "license": "MIT",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }