proctor-mcp-server 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.integration-with-mock.js +9 -1
- package/build/index.js +9 -1
- package/package.json +1 -1
- package/shared/index.d.ts +2 -1
- package/shared/index.js +1 -0
- package/shared/server.d.ts +4 -1
- package/shared/server.js +2 -2
- package/shared/tools/run-exam.d.ts +7 -0
- package/shared/tools/run-exam.js +35 -3
- package/shared/utils/truncation.d.ts +19 -0
- package/shared/utils/truncation.js +170 -0
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
* Integration test entry point with mock client
|
|
4
4
|
* This file is used for testing the MCP server with mocked external API calls
|
|
5
5
|
*/
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
6
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
10
|
import { createMCPServer, logServerStart, logError } from '../shared/index.js';
|
|
11
|
+
// Read version from package.json
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const packageJsonPath = join(__dirname, '..', 'package.json');
|
|
14
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
15
|
+
const VERSION = packageJson.version;
|
|
8
16
|
/**
|
|
9
17
|
* Integration mock implementation of IProctorClient
|
|
10
18
|
*/
|
|
@@ -87,7 +95,7 @@ class IntegrationMockProctorClient {
|
|
|
87
95
|
}
|
|
88
96
|
async function main() {
|
|
89
97
|
// Create server using factory
|
|
90
|
-
const { server, registerHandlers } = createMCPServer();
|
|
98
|
+
const { server, registerHandlers } = createMCPServer({ version: VERSION });
|
|
91
99
|
// Create mock client for testing
|
|
92
100
|
const mockClient = new IntegrationMockProctorClient();
|
|
93
101
|
// Register all handlers with mock client
|
package/build/index.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
2
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
6
|
import { createMCPServer } from '../shared/index.js';
|
|
4
7
|
import { logServerStart, logError } from '../shared/logging.js';
|
|
8
|
+
// Read version from package.json
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const packageJsonPath = join(__dirname, '..', 'package.json');
|
|
11
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
12
|
+
const VERSION = packageJson.version;
|
|
5
13
|
// Validate required environment variables before starting
|
|
6
14
|
function validateEnvironment() {
|
|
7
15
|
const required = [
|
|
@@ -42,7 +50,7 @@ async function main() {
|
|
|
42
50
|
// Validate environment variables first
|
|
43
51
|
validateEnvironment();
|
|
44
52
|
// Create server using factory
|
|
45
|
-
const { server, registerHandlers } = createMCPServer();
|
|
53
|
+
const { server, registerHandlers } = createMCPServer({ version: VERSION });
|
|
46
54
|
// Register all handlers (resources and tools)
|
|
47
55
|
await registerHandlers(server);
|
|
48
56
|
// Start server
|
package/package.json
CHANGED
package/shared/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { createMCPServer, ProctorClient } from './server.js';
|
|
2
|
-
export type { IProctorClient, ClientFactory } from './server.js';
|
|
2
|
+
export type { IProctorClient, ClientFactory, CreateMCPServerOptions } from './server.js';
|
|
3
3
|
export { createRegisterTools, parseEnabledToolGroups } from './tools.js';
|
|
4
4
|
export type { ToolGroup } from './tools.js';
|
|
5
5
|
export { logServerStart, logError, logWarning, logDebug } from './logging.js';
|
|
6
|
+
export { truncateStrings, deepClone } from './utils/truncation.js';
|
|
6
7
|
export type { ProctorRuntime, ProctorExam, ProctorMetadataResponse, ExamLogEntry, ExamStreamLog, ExamStreamResult, ExamStreamError, ExamStreamEntry, ExamResult, RunExamParams, FlyMachine, MachinesResponse, CancelExamParams, CancelExamResponse, ApiError, } from './types.js';
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
package/shared/index.js
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
export { createMCPServer, ProctorClient } from './server.js';
|
|
3
3
|
export { createRegisterTools, parseEnabledToolGroups } from './tools.js';
|
|
4
4
|
export { logServerStart, logError, logWarning, logDebug } from './logging.js';
|
|
5
|
+
export { truncateStrings, deepClone } from './utils/truncation.js';
|
package/shared/server.d.ts
CHANGED
|
@@ -22,7 +22,10 @@ export declare class ProctorClient implements IProctorClient {
|
|
|
22
22
|
cancelExam(params: CancelExamParams): Promise<CancelExamResponse>;
|
|
23
23
|
}
|
|
24
24
|
export type ClientFactory = () => IProctorClient;
|
|
25
|
-
export
|
|
25
|
+
export interface CreateMCPServerOptions {
|
|
26
|
+
version: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createMCPServer(options: CreateMCPServerOptions): {
|
|
26
29
|
server: Server<{
|
|
27
30
|
method: string;
|
|
28
31
|
params?: {
|
package/shared/server.js
CHANGED
|
@@ -29,10 +29,10 @@ export class ProctorClient {
|
|
|
29
29
|
return cancelExam(this.apiKey, this.baseUrl, params);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
export function createMCPServer() {
|
|
32
|
+
export function createMCPServer(options) {
|
|
33
33
|
const server = new Server({
|
|
34
34
|
name: 'proctor-mcp-server',
|
|
35
|
-
version:
|
|
35
|
+
version: options.version,
|
|
36
36
|
}, {
|
|
37
37
|
capabilities: {
|
|
38
38
|
tools: {},
|
|
@@ -58,6 +58,13 @@ export declare function runExam(_server: Server, clientFactory: ClientFactory):
|
|
|
58
58
|
};
|
|
59
59
|
required: string[];
|
|
60
60
|
};
|
|
61
|
+
expand_fields: {
|
|
62
|
+
type: string;
|
|
63
|
+
items: {
|
|
64
|
+
type: string;
|
|
65
|
+
};
|
|
66
|
+
description: "Array of dot-notation paths to show in full (not truncated). By default, long strings (>200 chars) and deep objects in exam results are auto-truncated to reduce response size. Use this to expand specific fields. Examples: [\"tools[].inputSchema\", \"input\"].";
|
|
67
|
+
};
|
|
61
68
|
};
|
|
62
69
|
required: string[];
|
|
63
70
|
};
|
package/shared/tools/run-exam.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { truncateStrings, deepClone } from '../utils/truncation.js';
|
|
2
3
|
// Parameter descriptions - single source of truth
|
|
3
4
|
const PARAM_DESCRIPTIONS = {
|
|
4
5
|
runtime_id: 'Runtime ID from get_proctor_metadata, or "__custom__" for a custom Docker image. Example: "v0.0.37"',
|
|
@@ -100,6 +101,7 @@ When provided, these credentials are passed to proctor-mcp-client which loads th
|
|
|
100
101
|
- client_id: OAuth client ID
|
|
101
102
|
- client_secret: OAuth client secret
|
|
102
103
|
- expires_at: ISO 8601 timestamp when the access token expires`,
|
|
104
|
+
expand_fields: 'Array of dot-notation paths to show in full (not truncated). By default, long strings (>200 chars) and deep objects in exam results are auto-truncated to reduce response size. Use this to expand specific fields. Examples: ["tools[].inputSchema", "input"].',
|
|
103
105
|
};
|
|
104
106
|
const PreloadedCredentialsSchema = z.object({
|
|
105
107
|
server_key: z.string().min(1),
|
|
@@ -110,6 +112,28 @@ const PreloadedCredentialsSchema = z.object({
|
|
|
110
112
|
client_secret: z.string().optional(),
|
|
111
113
|
expires_at: z.string().optional(),
|
|
112
114
|
});
|
|
115
|
+
// Preprocess to transform empty/incomplete objects to undefined before validation
|
|
116
|
+
// This handles cases where MCP clients send {}, null, or partial objects for optional parameters
|
|
117
|
+
const preprocessPreloadedCredentials = (val) => {
|
|
118
|
+
// If undefined, null, or not an object, let it pass through (or become undefined)
|
|
119
|
+
if (val === undefined || val === null) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (typeof val !== 'object') {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
// Check if it's an empty object or missing required fields
|
|
126
|
+
const obj = val;
|
|
127
|
+
if (Object.keys(obj).length === 0) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
if (!('server_key' in obj) || !('access_token' in obj)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
// Has required fields, validate it properly
|
|
134
|
+
return val;
|
|
135
|
+
};
|
|
136
|
+
const OptionalPreloadedCredentialsSchema = z.preprocess(preprocessPreloadedCredentials, PreloadedCredentialsSchema.optional());
|
|
113
137
|
const RunExamSchema = z.object({
|
|
114
138
|
runtime_id: z.string().min(1).describe(PARAM_DESCRIPTIONS.runtime_id),
|
|
115
139
|
exam_id: z.string().min(1).describe(PARAM_DESCRIPTIONS.exam_id),
|
|
@@ -117,7 +141,8 @@ const RunExamSchema = z.object({
|
|
|
117
141
|
server_json: z.string().optional().describe(PARAM_DESCRIPTIONS.server_json),
|
|
118
142
|
custom_runtime_image: z.string().optional().describe(PARAM_DESCRIPTIONS.custom_runtime_image),
|
|
119
143
|
max_retries: z.number().min(0).max(10).optional().describe(PARAM_DESCRIPTIONS.max_retries),
|
|
120
|
-
preloaded_credentials:
|
|
144
|
+
preloaded_credentials: OptionalPreloadedCredentialsSchema.optional().describe(PARAM_DESCRIPTIONS.preloaded_credentials),
|
|
145
|
+
expand_fields: z.array(z.string()).optional().describe(PARAM_DESCRIPTIONS.expand_fields),
|
|
121
146
|
});
|
|
122
147
|
export function runExam(_server, clientFactory) {
|
|
123
148
|
return {
|
|
@@ -149,7 +174,8 @@ The mcp_json parameter accepts a JSON object with server configurations. Each se
|
|
|
149
174
|
- Use get_proctor_metadata first to discover available runtimes and exams
|
|
150
175
|
- The mcp_json must be a valid JSON string representing the mcp.json format
|
|
151
176
|
- Custom runtime images require the "__custom__" runtime_id and custom_runtime_image parameter
|
|
152
|
-
- Underscore-prefixed fields in mcp_json are used for exam setup only and stripped before server execution
|
|
177
|
+
- Underscore-prefixed fields in mcp_json are used for exam setup only and stripped before server execution
|
|
178
|
+
- Results are auto-truncated to reduce response size. Long strings (>200 chars) and deep nested objects are replaced with truncation messages. Use the expand_fields parameter to retrieve full content for specific fields`,
|
|
153
179
|
inputSchema: {
|
|
154
180
|
type: 'object',
|
|
155
181
|
properties: {
|
|
@@ -191,6 +217,11 @@ The mcp_json parameter accepts a JSON object with server configurations. Each se
|
|
|
191
217
|
},
|
|
192
218
|
required: ['server_key', 'access_token'],
|
|
193
219
|
},
|
|
220
|
+
expand_fields: {
|
|
221
|
+
type: 'array',
|
|
222
|
+
items: { type: 'string' },
|
|
223
|
+
description: PARAM_DESCRIPTIONS.expand_fields,
|
|
224
|
+
},
|
|
194
225
|
},
|
|
195
226
|
required: ['runtime_id', 'exam_id', 'mcp_json'],
|
|
196
227
|
},
|
|
@@ -271,8 +302,9 @@ The mcp_json parameter accepts a JSON object with server configurations. Each se
|
|
|
271
302
|
};
|
|
272
303
|
}
|
|
273
304
|
if (finalResult) {
|
|
305
|
+
const truncatedResult = truncateStrings(deepClone(finalResult), validatedArgs.expand_fields || []);
|
|
274
306
|
content += '### Result\n\n```json\n';
|
|
275
|
-
content += JSON.stringify(
|
|
307
|
+
content += JSON.stringify(truncatedResult, null, 2);
|
|
276
308
|
content += '\n```\n';
|
|
277
309
|
}
|
|
278
310
|
return {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for string truncation and field expansion
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Recursively truncates values in an object:
|
|
6
|
+
* 1. Strings longer than 200 chars are replaced with truncation message
|
|
7
|
+
* 2. At depth >= 6, any value serializing to > 500 chars is replaced with truncation message
|
|
8
|
+
*
|
|
9
|
+
* @param obj - The object to process
|
|
10
|
+
* @param expandFields - Array of dot-notation paths to exclude from truncation
|
|
11
|
+
* @param currentPath - Current path in the object (for internal recursion)
|
|
12
|
+
* @returns Object with truncated values
|
|
13
|
+
*/
|
|
14
|
+
export declare function truncateStrings(obj: unknown, expandFields?: string[], currentPath?: string): unknown;
|
|
15
|
+
/**
|
|
16
|
+
* Deep clones an object using JSON serialization.
|
|
17
|
+
*/
|
|
18
|
+
export declare function deepClone<T>(obj: T): T;
|
|
19
|
+
//# sourceMappingURL=truncation.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for string truncation and field expansion
|
|
3
|
+
*/
|
|
4
|
+
const DEFAULT_STRING_MAX_LENGTH = 200;
|
|
5
|
+
const DEEP_VALUE_MAX_LENGTH = 500;
|
|
6
|
+
const DEPTH_THRESHOLD = 6; // Start truncating complex values at depth 6 (so depth 5 keys are visible)
|
|
7
|
+
/**
|
|
8
|
+
* Creates truncation message with specific path for expansion.
|
|
9
|
+
* Returns a complete message (not a suffix) to ensure JSON validity.
|
|
10
|
+
*/
|
|
11
|
+
function getTruncationMessage(path, type) {
|
|
12
|
+
const wildcardPath = path.replace(/\[\d+\]/g, '[]');
|
|
13
|
+
if (type === 'string') {
|
|
14
|
+
return `[TRUNCATED - use expand_fields: ["${wildcardPath}"] to see full content]`;
|
|
15
|
+
}
|
|
16
|
+
return `[DEEP OBJECT TRUNCATED - use expand_fields: ["${wildcardPath}"] to see full content]`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Calculates the depth of a path.
|
|
20
|
+
* Depth is counted as: each key access and each array index access.
|
|
21
|
+
* Examples:
|
|
22
|
+
* - "results" = depth 1
|
|
23
|
+
* - "results[0]" = depth 2
|
|
24
|
+
* - "results[0].tools" = depth 3
|
|
25
|
+
* - "results[0].tools[0]" = depth 4
|
|
26
|
+
* - "results[0].tools[0].inputSchema" = depth 5
|
|
27
|
+
* - "results[0].tools[0].inputSchema.properties" = depth 6 (truncation starts here)
|
|
28
|
+
*/
|
|
29
|
+
function getDepth(path) {
|
|
30
|
+
if (!path)
|
|
31
|
+
return 0;
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < path.length) {
|
|
35
|
+
// Skip to the next segment
|
|
36
|
+
if (path[i] === '.') {
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (path[i] === '[') {
|
|
41
|
+
// Array index or bracket notation - count as one depth level
|
|
42
|
+
depth++;
|
|
43
|
+
// Skip past the closing bracket
|
|
44
|
+
const closeBracket = path.indexOf(']', i);
|
|
45
|
+
if (closeBracket === -1)
|
|
46
|
+
break;
|
|
47
|
+
i = closeBracket + 1;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Key access - count as one depth level
|
|
51
|
+
depth++;
|
|
52
|
+
// Skip to the next delimiter
|
|
53
|
+
while (i < path.length && path[i] !== '.' && path[i] !== '[') {
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return depth;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Recursively truncates values in an object:
|
|
62
|
+
* 1. Strings longer than 200 chars are replaced with truncation message
|
|
63
|
+
* 2. At depth >= 6, any value serializing to > 500 chars is replaced with truncation message
|
|
64
|
+
*
|
|
65
|
+
* @param obj - The object to process
|
|
66
|
+
* @param expandFields - Array of dot-notation paths to exclude from truncation
|
|
67
|
+
* @param currentPath - Current path in the object (for internal recursion)
|
|
68
|
+
* @returns Object with truncated values
|
|
69
|
+
*/
|
|
70
|
+
export function truncateStrings(obj, expandFields = [], currentPath = '') {
|
|
71
|
+
if (obj === null || obj === undefined) {
|
|
72
|
+
return obj;
|
|
73
|
+
}
|
|
74
|
+
const currentDepth = getDepth(currentPath);
|
|
75
|
+
// Check if this path should be expanded (skip all truncation)
|
|
76
|
+
if (shouldExpand(currentPath, expandFields)) {
|
|
77
|
+
// Still need to recurse for nested paths that might not be expanded
|
|
78
|
+
if (Array.isArray(obj)) {
|
|
79
|
+
return obj.map((item, index) => {
|
|
80
|
+
const arrayPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`;
|
|
81
|
+
return truncateStrings(item, expandFields, arrayPath);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (typeof obj === 'object') {
|
|
85
|
+
const result = {};
|
|
86
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
87
|
+
const newPath = currentPath ? `${currentPath}.${key}` : key;
|
|
88
|
+
result[key] = truncateStrings(value, expandFields, newPath);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
return obj;
|
|
93
|
+
}
|
|
94
|
+
// At depth >= DEPTH_THRESHOLD, check if the serialized value is too large
|
|
95
|
+
if (currentDepth >= DEPTH_THRESHOLD && (typeof obj === 'object' || Array.isArray(obj))) {
|
|
96
|
+
const serialized = JSON.stringify(obj);
|
|
97
|
+
if (serialized.length > DEEP_VALUE_MAX_LENGTH) {
|
|
98
|
+
// Replace the entire value with a truncation message (keeps JSON valid)
|
|
99
|
+
return getTruncationMessage(currentPath, 'deep');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Handle strings - truncate if too long
|
|
103
|
+
if (typeof obj === 'string') {
|
|
104
|
+
if (obj.length > DEFAULT_STRING_MAX_LENGTH) {
|
|
105
|
+
// Replace with truncation message (keeps JSON valid)
|
|
106
|
+
return getTruncationMessage(currentPath, 'string');
|
|
107
|
+
}
|
|
108
|
+
return obj;
|
|
109
|
+
}
|
|
110
|
+
// Handle arrays
|
|
111
|
+
if (Array.isArray(obj)) {
|
|
112
|
+
return obj.map((item, index) => {
|
|
113
|
+
const arrayPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`;
|
|
114
|
+
return truncateStrings(item, expandFields, arrayPath);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// Handle objects
|
|
118
|
+
if (typeof obj === 'object') {
|
|
119
|
+
const result = {};
|
|
120
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
121
|
+
const newPath = currentPath ? `${currentPath}.${key}` : key;
|
|
122
|
+
result[key] = truncateStrings(value, expandFields, newPath);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
// For other types (numbers, booleans, etc.), return as-is
|
|
127
|
+
return obj;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Checks if a path should be expanded (not truncated).
|
|
131
|
+
* Supports exact matches, prefix matches, and array wildcard notation.
|
|
132
|
+
*
|
|
133
|
+
* Examples:
|
|
134
|
+
* - "results[0].tools[0].inputSchema" matches "results[].tools[].inputSchema"
|
|
135
|
+
* - "results.tests" matches "results.tests"
|
|
136
|
+
* - "results[0].tools[0].description" matches "results[].tools[].description"
|
|
137
|
+
*/
|
|
138
|
+
function shouldExpand(currentPath, expandFields) {
|
|
139
|
+
if (!currentPath || expandFields.length === 0) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
for (const expandPath of expandFields) {
|
|
143
|
+
// Exact match
|
|
144
|
+
if (currentPath === expandPath) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
// Check if expand path is a prefix (for nested expansion)
|
|
148
|
+
if (currentPath.startsWith(expandPath + '.') || currentPath.startsWith(expandPath + '[')) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
// Convert array indices to wildcards for comparison
|
|
152
|
+
// e.g., "results[0].tools[1].inputSchema" becomes "results[].tools[].inputSchema"
|
|
153
|
+
const normalizedCurrent = currentPath.replace(/\[\d+\]/g, '[]');
|
|
154
|
+
if (normalizedCurrent === expandPath) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
// Check prefix match with normalized path
|
|
158
|
+
if (normalizedCurrent.startsWith(expandPath + '.') ||
|
|
159
|
+
normalizedCurrent.startsWith(expandPath + '[')) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Deep clones an object using JSON serialization.
|
|
167
|
+
*/
|
|
168
|
+
export function deepClone(obj) {
|
|
169
|
+
return JSON.parse(JSON.stringify(obj));
|
|
170
|
+
}
|