json-explorer-mcp 1.0.2 → 1.0.4
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/README.md +102 -0
- package/dist/index.js +51 -7
- package/dist/tools/navigation.d.ts +23 -0
- package/dist/tools/navigation.js +106 -1
- package/dist/tools/structure.d.ts +6 -0
- package/dist/tools/structure.js +82 -10
- package/dist/utils/path-helpers.d.ts +6 -0
- package/dist/utils/path-helpers.js +109 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,6 +110,80 @@ Retrieve the value at a specific path. Large values are automatically truncated.
|
|
|
110
110
|
json_get(file: "/path/to/data.json", path: "$.users[0]")
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
### json_set
|
|
114
|
+
|
|
115
|
+
Set a value at a specific JSONPath with optional schema validation.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
json_set(
|
|
119
|
+
file: "/path/to/data.json",
|
|
120
|
+
path: "$.users[0].name",
|
|
121
|
+
value: "New Name"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
With schema validation (validates before writing):
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
json_set(
|
|
129
|
+
file: "/path/to/data.json",
|
|
130
|
+
path: "$.users[0]",
|
|
131
|
+
value: { id: 1, name: "Alice", email: "alice@example.com" },
|
|
132
|
+
schema: "/path/to/user-schema.json"
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
With inferred schema (infers type from existing value):
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
json_set(
|
|
140
|
+
file: "/path/to/data.json",
|
|
141
|
+
path: "$.config.maxRetries",
|
|
142
|
+
value: 5,
|
|
143
|
+
inferSchema: true // Will reject if new value type doesn't match existing
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
With dry run (validates without writing):
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
json_set(
|
|
151
|
+
file: "/path/to/data.json",
|
|
152
|
+
path: "$.config.enabled",
|
|
153
|
+
value: true,
|
|
154
|
+
dryRun: true, // Returns result without modifying file
|
|
155
|
+
outputFile: "/path/to/output.json" // Or write to different file
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"success": true,
|
|
164
|
+
"path": "$.config.enabled",
|
|
165
|
+
"previousValue": false,
|
|
166
|
+
"newValue": true,
|
|
167
|
+
"dryRun": false,
|
|
168
|
+
"outputFile": "/path/to/data.json"
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
On validation failure:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"success": false,
|
|
177
|
+
"path": "$.config.maxRetries",
|
|
178
|
+
"previousValue": 3,
|
|
179
|
+
"newValue": "not a number",
|
|
180
|
+
"validationErrors": [
|
|
181
|
+
{ "path": "$", "message": "must be integer" }
|
|
182
|
+
],
|
|
183
|
+
"dryRun": true
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
113
187
|
### json_schema
|
|
114
188
|
|
|
115
189
|
Infer the JSON schema/structure at a path. For arrays, samples items to determine the item schema.
|
|
@@ -144,6 +218,7 @@ Validate JSON data against a JSON Schema. Schema can be provided inline or as a
|
|
|
144
218
|
**Features:**
|
|
145
219
|
- Automatic resolution of local file `$ref` references
|
|
146
220
|
- Optional network `$ref` resolution (disabled by default)
|
|
221
|
+
- Schema registry for pre-registering schemas by URI (map remote refs to local files)
|
|
147
222
|
- Validates that referenced files are actual JSON Schemas
|
|
148
223
|
- Error limiting (default 10) to avoid huge error lists
|
|
149
224
|
|
|
@@ -177,6 +252,33 @@ json_validate(
|
|
|
177
252
|
)
|
|
178
253
|
```
|
|
179
254
|
|
|
255
|
+
With pre-registered schemas (for complex `$ref` scenarios):
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
json_validate(
|
|
259
|
+
file: "/path/to/data.json",
|
|
260
|
+
schema: "/path/to/schema.json",
|
|
261
|
+
schemas: {
|
|
262
|
+
// Map URIs to local files or inline schemas
|
|
263
|
+
"https://example.com/schemas/user.json": "/local/path/to/user-schema.json",
|
|
264
|
+
"https://example.com/schemas/address.json": { type: "object", properties: { ... } }
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
With schema directory (loads all schemas by their `$id`):
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
json_validate(
|
|
273
|
+
file: "/path/to/data.json",
|
|
274
|
+
schema: {}, // Not needed when using schemaDir+schemaId
|
|
275
|
+
schemaDir: "/path/to/schemas/", // Directory with .json schema files
|
|
276
|
+
schemaId: "main-schema" // $id of the entrypoint schema
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
This is useful when you have a directory of interconnected schemas that reference each other by `$id`. All `.json` files in the directory are loaded and registered by their `$id` (or filename if no `$id` is present).
|
|
281
|
+
|
|
180
282
|
Returns:
|
|
181
283
|
|
|
182
284
|
```json
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { jsonInspect, jsonSchema, jsonValidate } from "./tools/structure.js";
|
|
6
|
-
import { jsonKeys, jsonGet } from "./tools/navigation.js";
|
|
6
|
+
import { jsonKeys, jsonGet, jsonSet } from "./tools/navigation.js";
|
|
7
7
|
import { jsonSearch, jsonSample, jsonStats } from "./tools/query.js";
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "json-explorer",
|
|
10
|
-
version: "1.0.
|
|
10
|
+
version: "1.0.4",
|
|
11
11
|
});
|
|
12
12
|
// Tool: json_inspect - Get file overview
|
|
13
13
|
server.tool("json_inspect", "Get an overview of a JSON file including size, structure type, and a depth-limited preview. Use this first to understand what you're working with.", {
|
|
@@ -94,6 +94,44 @@ server.tool("json_get", "Retrieve the value at a specific path. Large values are
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
|
+
// Tool: json_set - Set value at path with optional schema validation
|
|
98
|
+
server.tool("json_set", "Set a value at a specific JSONPath. Supports schema validation (inline, file, or inferred from existing data) and dry-run mode. Can write to the source file or a different output file.", {
|
|
99
|
+
file: z.string().describe("Absolute path to the JSON file to modify"),
|
|
100
|
+
path: z.string().describe("JSONPath where to set the value (e.g., '$.users[0].name', 'config.enabled')"),
|
|
101
|
+
value: z.unknown().describe("The value to set at the path"),
|
|
102
|
+
schema: z.union([z.string(), z.object({}).passthrough()]).optional().describe("JSON Schema to validate value against (inline object or path to schema file)"),
|
|
103
|
+
inferSchema: z.boolean().optional().describe("If true, infers schema from existing value at path and validates against it. Defaults to false."),
|
|
104
|
+
dryRun: z.boolean().optional().describe("If true, validates and returns result without writing to file. Defaults to false."),
|
|
105
|
+
outputFile: z.string().optional().describe("Output file path. If not provided, writes to the source file."),
|
|
106
|
+
}, { readOnlyHint: false }, async ({ file, path, value, schema, inferSchema, dryRun, outputFile }) => {
|
|
107
|
+
try {
|
|
108
|
+
const result = await jsonSet(file, path, value, {
|
|
109
|
+
schema,
|
|
110
|
+
inferSchema,
|
|
111
|
+
dryRun,
|
|
112
|
+
outputFile,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: JSON.stringify(result, null, 2),
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "text",
|
|
128
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
isError: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
});
|
|
97
135
|
// Tool: json_schema - Infer structure
|
|
98
136
|
server.tool("json_schema", "Infer the JSON schema/structure at a path. For arrays, samples items to determine the item schema.", {
|
|
99
137
|
file: z.string().describe("Absolute path to the JSON file"),
|
|
@@ -123,19 +161,25 @@ server.tool("json_schema", "Infer the JSON schema/structure at a path. For array
|
|
|
123
161
|
}
|
|
124
162
|
});
|
|
125
163
|
// Tool: json_validate - Validate against JSON Schema
|
|
126
|
-
server.tool("json_validate", "Validate JSON data against a JSON Schema. Schema can be provided inline
|
|
164
|
+
server.tool("json_validate", "Validate JSON data against a JSON Schema. Schema can be provided inline, as a file path, or loaded from a directory by $id. Automatically resolves local file $refs. Use schemas to pre-register schemas by URI, or schemaDir+schemaId to load all schemas from a directory.", {
|
|
127
165
|
file: z.string().describe("Absolute path to the JSON file to validate"),
|
|
128
|
-
schema: z.union([z.string(), z.object({}).passthrough()]).describe("JSON Schema object or path to schema file"),
|
|
166
|
+
schema: z.union([z.string(), z.object({}).passthrough()]).optional().describe("JSON Schema object or path to schema file. Optional if using schemaDir+schemaId."),
|
|
129
167
|
path: z.string().optional().describe("JSONPath to validate. Defaults to root."),
|
|
130
168
|
errorLimit: z.number().optional().describe("Maximum number of errors to return. Defaults to 10."),
|
|
131
169
|
resolveLocalRefs: z.boolean().optional().describe("Resolve $ref to local files. Defaults to true."),
|
|
132
170
|
resolveNetworkRefs: z.boolean().optional().describe("Resolve $ref to HTTP URLs. Defaults to false for security."),
|
|
133
|
-
|
|
171
|
+
schemas: z.record(z.union([z.string(), z.object({}).passthrough()])).optional().describe("Pre-register schemas by URI. Map of URI to schema file path or inline schema object."),
|
|
172
|
+
schemaDir: z.string().optional().describe("Directory containing schema files. All .json files will be loaded and registered by their $id."),
|
|
173
|
+
schemaId: z.string().optional().describe("When using schemaDir, the $id of the schema to use as entrypoint."),
|
|
174
|
+
}, { readOnlyHint: true }, async ({ file, schema, path, errorLimit, resolveLocalRefs, resolveNetworkRefs, schemas, schemaDir, schemaId }) => {
|
|
134
175
|
try {
|
|
135
|
-
const result = await jsonValidate(file, schema, path, {
|
|
176
|
+
const result = await jsonValidate(file, schema ?? {}, path, {
|
|
136
177
|
errorLimit,
|
|
137
178
|
resolveLocalRefs,
|
|
138
179
|
resolveNetworkRefs,
|
|
180
|
+
schemas,
|
|
181
|
+
schemaDir,
|
|
182
|
+
schemaId,
|
|
139
183
|
});
|
|
140
184
|
return {
|
|
141
185
|
content: [
|
|
@@ -220,7 +264,7 @@ server.tool("json_sample", "Get sample items from an array. Supports first, last
|
|
|
220
264
|
}
|
|
221
265
|
});
|
|
222
266
|
// Tool: json_stats - Aggregate statistics for arrays
|
|
223
|
-
server.tool("json_stats", "Get aggregate statistics for
|
|
267
|
+
server.tool("json_stats", "Get aggregate statistics for arrays. Supports arrays of objects (field-level stats), strings (length distribution), numbers (min/max/avg/median/stdDev/percentiles), and mixed types. If no path provided, discovers and analyzes all arrays in the file.", {
|
|
224
268
|
file: z.string().describe("Absolute path to the JSON file"),
|
|
225
269
|
path: z.string().optional().describe("JSONPath to the array. If omitted, discovers and lists all arrays in the file."),
|
|
226
270
|
fields: z.array(z.string()).optional().describe("Specific fields to analyze. If not provided, analyzes all top-level fields."),
|
|
@@ -19,3 +19,26 @@ export interface GetResult {
|
|
|
19
19
|
originalSize?: number;
|
|
20
20
|
}
|
|
21
21
|
export declare function jsonGet(filePath: string, path: string, maxSize?: number): Promise<GetResult>;
|
|
22
|
+
export interface SetOptions {
|
|
23
|
+
/** JSON Schema to validate against (inline object or file path) */
|
|
24
|
+
schema?: object | string;
|
|
25
|
+
/** Infer schema from existing value at path. Defaults to false. */
|
|
26
|
+
inferSchema?: boolean;
|
|
27
|
+
/** If true, only validate without writing. Returns the modified JSON. */
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
/** Output file path. If not provided, writes to the source file. */
|
|
30
|
+
outputFile?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SetResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
path: string;
|
|
35
|
+
previousValue?: unknown;
|
|
36
|
+
newValue: unknown;
|
|
37
|
+
validationErrors?: Array<{
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}>;
|
|
41
|
+
outputFile?: string;
|
|
42
|
+
dryRun: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function jsonSet(filePath: string, path: string, value: unknown, options?: SetOptions): Promise<SetResult>;
|
package/dist/tools/navigation.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadJson, getValueType, truncateValue, getValuePreview } from "../utils/json-parser.js";
|
|
2
|
-
import { getValueAtPath, getKeysAtPath, buildPathTo } from "../utils/path-helpers.js";
|
|
2
|
+
import { getValueAtPath, getKeysAtPath, buildPathTo, setValueAtPath } from "../utils/path-helpers.js";
|
|
3
|
+
import { writeFile } from "fs/promises";
|
|
3
4
|
export async function jsonKeys(filePath, path, limit = 50) {
|
|
4
5
|
const data = await loadJson(filePath);
|
|
5
6
|
const targetPath = path || "$";
|
|
@@ -95,3 +96,107 @@ export async function jsonGet(filePath, path, maxSize = 5000) {
|
|
|
95
96
|
...(truncated && { originalSize }),
|
|
96
97
|
};
|
|
97
98
|
}
|
|
99
|
+
// Dynamic import for ajv (ESM/CJS compat)
|
|
100
|
+
async function getAjv() {
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
const ajvModule = await import("ajv");
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
const formatsModule = await import("ajv-formats");
|
|
105
|
+
const Ajv = ajvModule.default || ajvModule;
|
|
106
|
+
const addFormats = formatsModule.default || formatsModule;
|
|
107
|
+
return { Ajv, addFormats };
|
|
108
|
+
}
|
|
109
|
+
// Infer a simple JSON Schema from a value
|
|
110
|
+
function inferSimpleSchema(value) {
|
|
111
|
+
if (value === null) {
|
|
112
|
+
return { type: "null" };
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (value.length === 0) {
|
|
116
|
+
return { type: "array" };
|
|
117
|
+
}
|
|
118
|
+
// Sample first item for items schema
|
|
119
|
+
return { type: "array", items: inferSimpleSchema(value[0]) };
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "object") {
|
|
122
|
+
const properties = {};
|
|
123
|
+
for (const [key, val] of Object.entries(value)) {
|
|
124
|
+
properties[key] = inferSimpleSchema(val);
|
|
125
|
+
}
|
|
126
|
+
return { type: "object", properties };
|
|
127
|
+
}
|
|
128
|
+
if (typeof value === "number") {
|
|
129
|
+
return Number.isInteger(value) ? { type: "integer" } : { type: "number" };
|
|
130
|
+
}
|
|
131
|
+
return { type: typeof value };
|
|
132
|
+
}
|
|
133
|
+
export async function jsonSet(filePath, path, value, options = {}) {
|
|
134
|
+
const { schema, inferSchema: shouldInferSchema = false, dryRun = false, outputFile, } = options;
|
|
135
|
+
// Load the JSON file
|
|
136
|
+
const data = await loadJson(filePath);
|
|
137
|
+
// Deep clone to avoid mutating cached data
|
|
138
|
+
const clonedData = JSON.parse(JSON.stringify(data));
|
|
139
|
+
// Get previous value at path (may be undefined if path doesn't exist yet)
|
|
140
|
+
const previousValue = getValueAtPath(clonedData, path);
|
|
141
|
+
// Determine schema for validation
|
|
142
|
+
let schemaToValidate;
|
|
143
|
+
if (schema) {
|
|
144
|
+
if (typeof schema === "string") {
|
|
145
|
+
// Load schema from file
|
|
146
|
+
schemaToValidate = (await loadJson(schema));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
schemaToValidate = schema;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (shouldInferSchema && previousValue !== undefined) {
|
|
153
|
+
// Infer schema from existing value
|
|
154
|
+
schemaToValidate = inferSimpleSchema(previousValue);
|
|
155
|
+
}
|
|
156
|
+
// Validate the new value against schema
|
|
157
|
+
if (schemaToValidate) {
|
|
158
|
+
const { Ajv, addFormats } = await getAjv();
|
|
159
|
+
const ajv = new Ajv({ allErrors: true });
|
|
160
|
+
addFormats(ajv);
|
|
161
|
+
const validate = ajv.compile(schemaToValidate);
|
|
162
|
+
const valid = validate(value);
|
|
163
|
+
if (!valid) {
|
|
164
|
+
const errors = (validate.errors || []).map((err) => ({
|
|
165
|
+
path: err.instancePath || "$",
|
|
166
|
+
message: err.message || "Unknown error",
|
|
167
|
+
}));
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
path,
|
|
171
|
+
previousValue,
|
|
172
|
+
newValue: value,
|
|
173
|
+
validationErrors: errors,
|
|
174
|
+
dryRun: true, // Force dryRun on validation failure
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Set the value at path
|
|
179
|
+
const modifiedData = setValueAtPath(clonedData, path, value);
|
|
180
|
+
// If dry run, return the modified data without writing
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
path,
|
|
185
|
+
previousValue,
|
|
186
|
+
newValue: value,
|
|
187
|
+
dryRun: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Write to file
|
|
191
|
+
const targetFile = outputFile || filePath;
|
|
192
|
+
const jsonOutput = JSON.stringify(modifiedData, null, 2) + "\n";
|
|
193
|
+
await writeFile(targetFile, jsonOutput, "utf-8");
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
path,
|
|
197
|
+
previousValue,
|
|
198
|
+
newValue: value,
|
|
199
|
+
outputFile: targetFile,
|
|
200
|
+
dryRun: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -35,6 +35,12 @@ export interface ValidateOptions {
|
|
|
35
35
|
errorLimit?: number;
|
|
36
36
|
resolveLocalRefs?: boolean;
|
|
37
37
|
resolveNetworkRefs?: boolean;
|
|
38
|
+
/** Pre-register schemas by URI. Value can be inline schema object or path to schema file. */
|
|
39
|
+
schemas?: Record<string, object | string>;
|
|
40
|
+
/** Directory containing schema files. All .json files will be loaded and registered by their $id. */
|
|
41
|
+
schemaDir?: string;
|
|
42
|
+
/** When using schemaDir, specify the $id of the schema to use as entrypoint (instead of schema param). */
|
|
43
|
+
schemaId?: string;
|
|
38
44
|
}
|
|
39
45
|
export declare function jsonValidate(filePath: string, schema: object | string, path?: string, options?: ValidateOptions | number): Promise<ValidateResult>;
|
|
40
46
|
export {};
|
package/dist/tools/structure.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadJson, getFileInfo, formatBytes, getValueType } from "../utils/json-parser.js";
|
|
2
2
|
import { getDepthPreview, getValueAtPath } from "../utils/path-helpers.js";
|
|
3
|
-
import { dirname, resolve, isAbsolute } from "path";
|
|
3
|
+
import { dirname, resolve, isAbsolute, join } from "path";
|
|
4
|
+
import { readdir } from "fs/promises";
|
|
4
5
|
// Dynamic import for ajv (ESM/CJS compat)
|
|
5
6
|
async function getAjv() {
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -213,6 +214,34 @@ function isValidJsonSchema(obj) {
|
|
|
213
214
|
const isEmpty = Object.keys(schema).length === 0;
|
|
214
215
|
return hasSchemaKeyword || isEmpty;
|
|
215
216
|
}
|
|
217
|
+
// Load all JSON schema files from a directory, returning map of $id -> schema
|
|
218
|
+
async function loadSchemasFromDir(dir) {
|
|
219
|
+
const schemas = new Map();
|
|
220
|
+
const files = await readdir(dir);
|
|
221
|
+
const jsonFiles = files.filter(f => f.endsWith(".json"));
|
|
222
|
+
for (const file of jsonFiles) {
|
|
223
|
+
const filePath = join(dir, file);
|
|
224
|
+
try {
|
|
225
|
+
const schema = await loadJson(filePath);
|
|
226
|
+
if (!isValidJsonSchema(schema)) {
|
|
227
|
+
continue; // Skip non-schema JSON files
|
|
228
|
+
}
|
|
229
|
+
const schemaRecord = schema;
|
|
230
|
+
const id = schemaRecord.$id;
|
|
231
|
+
if (id) {
|
|
232
|
+
schemas.set(id, { schema, file: filePath });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// Use filename as fallback id
|
|
236
|
+
schemas.set(file, { schema, file: filePath });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Skip files that can't be loaded
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return schemas;
|
|
244
|
+
}
|
|
216
245
|
// Fetch a schema from a URL
|
|
217
246
|
async function fetchNetworkSchema(url) {
|
|
218
247
|
const response = await fetch(url);
|
|
@@ -238,24 +267,36 @@ export async function jsonValidate(filePath, schema, path, options = {}) {
|
|
|
238
267
|
: options;
|
|
239
268
|
// Environment variable can completely disable network refs for security
|
|
240
269
|
const networkRefsDisabled = process.env.JSON_EXPLORER_NO_NETWORK === "1";
|
|
241
|
-
const { errorLimit = 10, resolveLocalRefs = true, resolveNetworkRefs = false, } = opts;
|
|
270
|
+
const { errorLimit = 10, resolveLocalRefs = true, resolveNetworkRefs = false, schemas = {}, schemaDir, schemaId, } = opts;
|
|
242
271
|
const effectiveResolveNetworkRefs = resolveNetworkRefs && !networkRefsDisabled;
|
|
243
272
|
const data = await loadJson(filePath);
|
|
244
273
|
const targetData = path ? getValueAtPath(data, path) : data;
|
|
245
274
|
if (targetData === undefined) {
|
|
246
275
|
throw new Error(`Path not found: ${path}`);
|
|
247
276
|
}
|
|
248
|
-
// Load schema from file
|
|
277
|
+
// Load schema - from schemaDir+schemaId, file path, or inline
|
|
249
278
|
let schemaObj;
|
|
250
|
-
let
|
|
251
|
-
|
|
279
|
+
let schemaBaseDir;
|
|
280
|
+
let dirSchemas;
|
|
281
|
+
if (schemaDir && schemaId) {
|
|
282
|
+
// Load all schemas from directory and use schemaId as entrypoint
|
|
283
|
+
dirSchemas = await loadSchemasFromDir(schemaDir);
|
|
284
|
+
const entry = dirSchemas.get(schemaId);
|
|
285
|
+
if (!entry) {
|
|
286
|
+
const availableIds = Array.from(dirSchemas.keys()).join(", ");
|
|
287
|
+
throw new Error(`Schema with $id "${schemaId}" not found in ${schemaDir}. Available: ${availableIds}`);
|
|
288
|
+
}
|
|
289
|
+
schemaObj = entry.schema;
|
|
290
|
+
schemaBaseDir = schemaDir;
|
|
291
|
+
}
|
|
292
|
+
else if (typeof schema === "string") {
|
|
252
293
|
schemaObj = (await loadJson(schema));
|
|
253
|
-
|
|
294
|
+
schemaBaseDir = dirname(resolve(schema));
|
|
254
295
|
}
|
|
255
296
|
else {
|
|
256
297
|
schemaObj = schema;
|
|
257
298
|
// For inline schemas, use current working directory
|
|
258
|
-
|
|
299
|
+
schemaBaseDir = process.cwd();
|
|
259
300
|
}
|
|
260
301
|
const { Ajv, addFormats } = await getAjv();
|
|
261
302
|
// Configure ajv options
|
|
@@ -272,10 +313,41 @@ export async function jsonValidate(filePath, schema, path, options = {}) {
|
|
|
272
313
|
}
|
|
273
314
|
const ajv = new Ajv(ajvOptions);
|
|
274
315
|
addFormats(ajv);
|
|
275
|
-
// Pre-
|
|
316
|
+
// Pre-register user-provided schemas
|
|
276
317
|
const resolvedRefs = [];
|
|
277
|
-
|
|
278
|
-
|
|
318
|
+
for (const [uri, schemaValue] of Object.entries(schemas)) {
|
|
319
|
+
let schemaToAdd;
|
|
320
|
+
if (typeof schemaValue === "string") {
|
|
321
|
+
// Load from file
|
|
322
|
+
const loaded = await loadJson(schemaValue);
|
|
323
|
+
if (!isValidJsonSchema(loaded)) {
|
|
324
|
+
throw new Error(`Schema file ${schemaValue} is not a valid JSON Schema`);
|
|
325
|
+
}
|
|
326
|
+
schemaToAdd = loaded;
|
|
327
|
+
resolvedRefs.push(schemaValue);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Inline schema object
|
|
331
|
+
if (!isValidJsonSchema(schemaValue)) {
|
|
332
|
+
throw new Error(`Inline schema for "${uri}" is not a valid JSON Schema`);
|
|
333
|
+
}
|
|
334
|
+
schemaToAdd = schemaValue;
|
|
335
|
+
}
|
|
336
|
+
ajv.addSchema(schemaToAdd, uri);
|
|
337
|
+
}
|
|
338
|
+
// Register all schemas from schemaDir (if provided)
|
|
339
|
+
if (dirSchemas) {
|
|
340
|
+
for (const [id, { schema: dirSchema, file }] of dirSchemas) {
|
|
341
|
+
// Don't re-register the entrypoint schema
|
|
342
|
+
if (id !== schemaId) {
|
|
343
|
+
ajv.addSchema(dirSchema, id);
|
|
344
|
+
resolvedRefs.push(file);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Pre-load local refs if enabled (skip if using schemaDir since all schemas are already loaded)
|
|
349
|
+
if (resolveLocalRefs && !dirSchemas) {
|
|
350
|
+
const localRefs = await loadLocalRefs(schemaObj, schemaBaseDir);
|
|
279
351
|
for (const [refPath, refSchema] of localRefs) {
|
|
280
352
|
// If schema has $id, ajv will use that for resolution
|
|
281
353
|
// Otherwise we add it by its relative filename
|
|
@@ -3,4 +3,10 @@ export declare function normalizeToJsonPath(path: string): string;
|
|
|
3
3
|
export declare function getKeysAtPath(data: unknown, path: string): string[];
|
|
4
4
|
export declare function buildPathTo(basePath: string, key: string): string;
|
|
5
5
|
export declare function findPaths(data: unknown, predicate: (value: unknown, path: string) => boolean, currentPath?: string, maxResults?: number): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Set a value at a specific JSONPath in the data.
|
|
8
|
+
* Mutates the data in place and returns the modified data.
|
|
9
|
+
* Throws if the path doesn't exist (parent must exist).
|
|
10
|
+
*/
|
|
11
|
+
export declare function setValueAtPath(data: unknown, path: string, value: unknown): unknown;
|
|
6
12
|
export declare function getDepthPreview(data: unknown, maxDepth?: number, currentDepth?: number): unknown;
|
|
@@ -70,6 +70,115 @@ export function findPaths(data, predicate, currentPath = "$", maxResults = 100)
|
|
|
70
70
|
traverse(data, currentPath);
|
|
71
71
|
return results;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse a JSONPath into segments for traversal.
|
|
75
|
+
* Returns array of keys/indices to traverse.
|
|
76
|
+
*/
|
|
77
|
+
function parsePathSegments(path) {
|
|
78
|
+
const normalized = normalizeToJsonPath(path);
|
|
79
|
+
if (normalized === "$")
|
|
80
|
+
return [];
|
|
81
|
+
const segments = [];
|
|
82
|
+
// Remove leading $
|
|
83
|
+
const pathWithoutRoot = normalized.slice(1);
|
|
84
|
+
// Match either .key (including $-prefixed keys like $id), [index], or ["key"]
|
|
85
|
+
// Keys can start with letter, underscore, or $ and contain alphanumeric, underscore, or $
|
|
86
|
+
const regex = /\.([a-zA-Z_$][a-zA-Z0-9_$]*)|\.?(\[(\d+)\])|\.?\["([^"]+)"\]|\.?\['([^']+)'\]/g;
|
|
87
|
+
let match;
|
|
88
|
+
let lastIndex = 0;
|
|
89
|
+
while ((match = regex.exec(pathWithoutRoot)) !== null) {
|
|
90
|
+
// Check for gaps in parsing (unmatched content)
|
|
91
|
+
if (match.index > lastIndex) {
|
|
92
|
+
const unmatched = pathWithoutRoot.slice(lastIndex, match.index);
|
|
93
|
+
if (unmatched && unmatched !== ".") {
|
|
94
|
+
throw new Error(`Invalid path segment: could not parse "${unmatched}" in path "${path}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lastIndex = regex.lastIndex;
|
|
98
|
+
if (match[1] !== undefined) {
|
|
99
|
+
// .key format
|
|
100
|
+
segments.push(match[1]);
|
|
101
|
+
}
|
|
102
|
+
else if (match[3] !== undefined) {
|
|
103
|
+
// [index] format
|
|
104
|
+
segments.push(parseInt(match[3], 10));
|
|
105
|
+
}
|
|
106
|
+
else if (match[4] !== undefined) {
|
|
107
|
+
// ["key"] format
|
|
108
|
+
segments.push(match[4]);
|
|
109
|
+
}
|
|
110
|
+
else if (match[5] !== undefined) {
|
|
111
|
+
// ['key'] format
|
|
112
|
+
segments.push(match[5]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Check for trailing unmatched content
|
|
116
|
+
if (lastIndex < pathWithoutRoot.length) {
|
|
117
|
+
const unmatched = pathWithoutRoot.slice(lastIndex);
|
|
118
|
+
if (unmatched && unmatched !== ".") {
|
|
119
|
+
throw new Error(`Invalid path segment: could not parse "${unmatched}" in path "${path}"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Safety check: if path has content but no segments were parsed, something is wrong
|
|
123
|
+
if (pathWithoutRoot.length > 0 && segments.length === 0) {
|
|
124
|
+
throw new Error(`Failed to parse path "${path}": no valid segments found`);
|
|
125
|
+
}
|
|
126
|
+
return segments;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Set a value at a specific JSONPath in the data.
|
|
130
|
+
* Mutates the data in place and returns the modified data.
|
|
131
|
+
* Throws if the path doesn't exist (parent must exist).
|
|
132
|
+
*/
|
|
133
|
+
export function setValueAtPath(data, path, value) {
|
|
134
|
+
const segments = parsePathSegments(path);
|
|
135
|
+
if (segments.length === 0) {
|
|
136
|
+
// Setting root - just return the new value
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
// Traverse to parent
|
|
140
|
+
let current = data;
|
|
141
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
142
|
+
const segment = segments[i];
|
|
143
|
+
if (current === null || typeof current !== "object") {
|
|
144
|
+
throw new Error(`Cannot traverse path: parent at segment "${segment}" is not an object or array`);
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(current)) {
|
|
147
|
+
if (typeof segment !== "number") {
|
|
148
|
+
throw new Error(`Cannot use string key "${segment}" on array`);
|
|
149
|
+
}
|
|
150
|
+
if (segment < 0 || segment >= current.length) {
|
|
151
|
+
throw new Error(`Array index ${segment} out of bounds (length: ${current.length})`);
|
|
152
|
+
}
|
|
153
|
+
current = current[segment];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const key = String(segment);
|
|
157
|
+
if (!(key in current)) {
|
|
158
|
+
throw new Error(`Key "${key}" not found in object`);
|
|
159
|
+
}
|
|
160
|
+
current = current[key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Set the value at the final segment
|
|
164
|
+
const finalSegment = segments[segments.length - 1];
|
|
165
|
+
if (current === null || typeof current !== "object") {
|
|
166
|
+
throw new Error(`Cannot set value: parent is not an object or array`);
|
|
167
|
+
}
|
|
168
|
+
if (Array.isArray(current)) {
|
|
169
|
+
if (typeof finalSegment !== "number") {
|
|
170
|
+
throw new Error(`Cannot use string key "${finalSegment}" on array`);
|
|
171
|
+
}
|
|
172
|
+
if (finalSegment < 0 || finalSegment >= current.length) {
|
|
173
|
+
throw new Error(`Array index ${finalSegment} out of bounds (length: ${current.length})`);
|
|
174
|
+
}
|
|
175
|
+
current[finalSegment] = value;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
current[String(finalSegment)] = value;
|
|
179
|
+
}
|
|
180
|
+
return data;
|
|
181
|
+
}
|
|
73
182
|
export function getDepthPreview(data, maxDepth = 2, currentDepth = 0) {
|
|
74
183
|
if (currentDepth >= maxDepth) {
|
|
75
184
|
if (data === null)
|