diff --git a/scripts/tools.json b/scripts/tools.json index fde8a1c..2574cd2 100644 --- a/scripts/tools.json +++ b/scripts/tools.json @@ -19,6 +19,119 @@ "default": 100, "description": "The number of items to return" }, + "filters": { + "type": "object", + "description": "Filter expression with AND/OR logic. Use 'conditions' for filter criteria.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "and", + "or" + ], + "description": "Logical operator to combine conditions (default: and)" + }, + "conditions": { + "type": "array", + "description": "Array of filter conditions to apply", + "items": { + "type": "object", + "properties": { + "property_key": { + "type": "string", + "description": "The property key to filter on (e.g., 'done', 'created_date', 'links')" + }, + "condition": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "empty", + "nempty", + "in", + "nin", + "contains", + "ncontains", + "all" + ], + "description": "Filter condition: eq/ne (equals), gt/gte/lt/lte (comparison), empty/nempty (null check), in/nin/all (arrays), contains/ncontains (text)" + }, + "text": { + "type": "string", + "description": "Text value for text property filters" + }, + "number": { + "type": "number", + "description": "Number value for number property filters" + }, + "checkbox": { + "type": "boolean", + "description": "Boolean value for checkbox property filters" + }, + "date": { + "type": "string", + "description": "ISO 8601 date for date property filters (e.g., '2026-01-29T00:00:00Z')" + }, + "select": { + "type": "string", + "description": "Tag ID for single-select property filters" + }, + "multi_select": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag IDs for multi-select property filters" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of object IDs for relation property filters" + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file IDs for file property filters" + }, + "url": { + "type": "string", + "description": "URL value for URL property filters" + }, + "email": { + "type": "string", + "description": "Email value for email property filters" + }, + "phone": { + "type": "string", + "description": "Phone value for phone property filters" + } + }, + "required": [ + "property_key", + "condition" + ] + } + }, + "filters": { + "type": "array", + "description": "Nested filter expressions for complex AND/OR logic (optional)", + "items": { + "type": "object", + "additionalProperties": true, + "description": "Nested FilterExpression (same structure as parent)" + } + } + }, + "additionalProperties": false + }, "query": { "type": "string", "description": "The text to search within object names and content; use types field for type filtering" @@ -4419,6 +4532,119 @@ "default": 100, "description": "The number of items to return" }, + "filters": { + "type": "object", + "description": "Filter expression with AND/OR logic. Use 'conditions' for filter criteria.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "and", + "or" + ], + "description": "Logical operator to combine conditions (default: and)" + }, + "conditions": { + "type": "array", + "description": "Array of filter conditions to apply", + "items": { + "type": "object", + "properties": { + "property_key": { + "type": "string", + "description": "The property key to filter on (e.g., 'done', 'created_date', 'links')" + }, + "condition": { + "type": "string", + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "empty", + "nempty", + "in", + "nin", + "contains", + "ncontains", + "all" + ], + "description": "Filter condition: eq/ne (equals), gt/gte/lt/lte (comparison), empty/nempty (null check), in/nin/all (arrays), contains/ncontains (text)" + }, + "text": { + "type": "string", + "description": "Text value for text property filters" + }, + "number": { + "type": "number", + "description": "Number value for number property filters" + }, + "checkbox": { + "type": "boolean", + "description": "Boolean value for checkbox property filters" + }, + "date": { + "type": "string", + "description": "ISO 8601 date for date property filters (e.g., '2026-01-29T00:00:00Z')" + }, + "select": { + "type": "string", + "description": "Tag ID for single-select property filters" + }, + "multi_select": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag IDs for multi-select property filters" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of object IDs for relation property filters" + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file IDs for file property filters" + }, + "url": { + "type": "string", + "description": "URL value for URL property filters" + }, + "email": { + "type": "string", + "description": "Email value for email property filters" + }, + "phone": { + "type": "string", + "description": "Phone value for phone property filters" + } + }, + "required": [ + "property_key", + "condition" + ] + } + }, + "filters": { + "type": "array", + "description": "Nested filter expressions for complex AND/OR logic (optional)", + "items": { + "type": "object", + "additionalProperties": true, + "description": "Nested FilterExpression (same structure as parent)" + } + } + }, + "additionalProperties": false + }, "query": { "type": "string", "description": "The text to search within object names and content; use types field for type filtering" diff --git a/src/openapi/__tests__/parser-filters.test.ts b/src/openapi/__tests__/parser-filters.test.ts new file mode 100644 index 0000000..940b5a1 --- /dev/null +++ b/src/openapi/__tests__/parser-filters.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from "fs"; +import { JSONSchema7 as IJsonSchema } from "json-schema"; +import { OpenAPIV3 } from "openapi-types"; +import { fileURLToPath } from "url"; +import { dirname, resolve } from "path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { OpenAPIToMCPConverter } from "../parser"; + +// Regression tests for the flattened `FilterExpression` schema (PR #32). +// +// The real Anytype spec models `filters` as a recursive `FilterExpression` +// whose `conditions` are a 12-variant `oneOf` (`FilterItem`). We flatten that +// into a single agent-friendly object. These tests pin that flattening against +// the checked-in spec so it can't silently regress back to `{}` or drop fields. + +const SPEC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../../../scripts/openapi.json"); + +// The full set of value fields, one per `FilterItem` variant in the spec. +const VALUE_FIELDS: Record = { + text: "string", + number: "number", + checkbox: "boolean", + date: "string", + select: "string", + multi_select: "array", + objects: "array", + files: "array", + url: "string", + email: "string", + phone: "string", +}; + +// Short-form condition enum the API actually accepts (see FilterCondition +// x-enum-varnames). All 13 must be exposed. +const EXPECTED_CONDITIONS = [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "empty", + "nempty", + "in", + "nin", + "contains", + "ncontains", + "all", +]; + +describe("FilterExpression flattening", () => { + let filtersBySearchTool: Record; + + beforeAll(() => { + const spec = JSON.parse(readFileSync(SPEC_PATH, "utf-8")) as OpenAPIV3.Document; + const converter = new OpenAPIToMCPConverter(spec); + const { tools } = converter.convertToMCPTools(); + + const methods = Object.values(tools).flatMap((tool) => tool.methods); + filtersBySearchTool = {}; + for (const name of ["search-space", "search-global"]) { + const method = methods.find((m) => m.name === name); + expect(method, `tool ${name} should exist`).toBeDefined(); + filtersBySearchTool[name] = (method!.inputSchema as IJsonSchema).properties!.filters as IJsonSchema; + } + }); + + it("registers both search tools", () => { + expect(Object.keys(filtersBySearchTool).sort()).toEqual(["search-global", "search-space"]); + }); + + for (const toolName of ["search-space", "search-global"]) { + describe(toolName, () => { + it("exposes a flattened filters object (not an empty {} or a $ref)", () => { + const filters = filtersBySearchTool[toolName]; + expect(filters).toBeDefined(); + expect(filters.$ref).toBeUndefined(); + expect(filters.type).toBe("object"); + expect(filters.properties).toBeDefined(); + expect(Object.keys(filters.properties!)).toEqual(expect.arrayContaining(["operator", "conditions", "filters"])); + }); + + it("exposes the and/or operator enum", () => { + const operator = filtersBySearchTool[toolName].properties!.operator as IJsonSchema; + expect(operator.type).toBe("string"); + expect(operator.enum).toEqual(["and", "or"]); + }); + + it("exposes the full condition enum", () => { + const item = (filtersBySearchTool[toolName].properties!.conditions as IJsonSchema).items as IJsonSchema; + const condition = item.properties!.condition as IJsonSchema; + expect(condition.enum).toEqual(EXPECTED_CONDITIONS); + }); + + it("requires property_key and condition on each item", () => { + const item = (filtersBySearchTool[toolName].properties!.conditions as IJsonSchema).items as IJsonSchema; + expect(item.required).toEqual(["property_key", "condition"]); + expect((item.properties!.property_key as IJsonSchema).type).toBe("string"); + }); + + it("exposes every type-specific value field", () => { + const item = (filtersBySearchTool[toolName].properties!.conditions as IJsonSchema).items as IJsonSchema; + for (const [field, type] of Object.entries(VALUE_FIELDS)) { + const prop = item.properties![field] as IJsonSchema; + expect(prop, `value field ${field} should exist`).toBeDefined(); + expect(prop.type, `value field ${field} type`).toBe(type); + if (type === "array") { + expect((prop.items as IJsonSchema).type, `value field ${field} items`).toBe("string"); + } + } + }); + + it("supports nested filter expressions", () => { + const nested = filtersBySearchTool[toolName].properties!.filters as IJsonSchema; + expect(nested.type).toBe("array"); + expect((nested.items as IJsonSchema).type).toBe("object"); + }); + }); + } +}); diff --git a/src/openapi/parser.ts b/src/openapi/parser.ts index 0fad43d..67f89a6 100644 --- a/src/openapi/parser.ts +++ b/src/openapi/parser.ts @@ -56,9 +56,111 @@ export class OpenAPIToMCPConverter { ): IJsonSchema { if ("$ref" in schema) { const ref = schema.$ref; - // TODO: Add support for filters + // FilterExpression - flatten the complex oneOf into an agent-friendly schema if (ref === "#/components/schemas/FilterExpression") { - return {}; + return { + type: "object", + description: "Filter expression with AND/OR logic. Use 'conditions' for filter criteria.", + properties: { + operator: { + type: "string", + enum: ["and", "or"], + description: "Logical operator to combine conditions (default: and)", + }, + conditions: { + type: "array", + description: "Array of filter conditions to apply", + items: { + type: "object", + properties: { + property_key: { + type: "string", + description: "The property key to filter on (e.g., 'done', 'created_date', 'links')", + }, + condition: { + type: "string", + enum: [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "empty", + "nempty", + "in", + "nin", + "contains", + "ncontains", + "all", + ], + description: + "Filter condition: eq/ne (equals), gt/gte/lt/lte (comparison), empty/nempty (null check), in/nin/all (arrays), contains/ncontains (text)", + }, + // Type-specific value fields - use the one matching your property type + text: { + type: "string", + description: "Text value for text property filters", + }, + number: { + type: "number", + description: "Number value for number property filters", + }, + checkbox: { + type: "boolean", + description: "Boolean value for checkbox property filters", + }, + date: { + type: "string", + description: "ISO 8601 date for date property filters (e.g., '2026-01-29T00:00:00Z')", + }, + select: { + type: "string", + description: "Tag ID for single-select property filters", + }, + multi_select: { + type: "array", + items: { type: "string" }, + description: "Array of tag IDs for multi-select property filters", + }, + objects: { + type: "array", + items: { type: "string" }, + description: "Array of object IDs for relation property filters", + }, + files: { + type: "array", + items: { type: "string" }, + description: "Array of file IDs for file property filters", + }, + url: { + type: "string", + description: "URL value for URL property filters", + }, + email: { + type: "string", + description: "Email value for email property filters", + }, + phone: { + type: "string", + description: "Phone value for phone property filters", + }, + }, + required: ["property_key", "condition"], + }, + }, + filters: { + type: "array", + description: "Nested filter expressions for complex AND/OR logic (optional)", + items: { + type: "object", + additionalProperties: true, + description: "Nested FilterExpression (same structure as parent)", + }, + }, + }, + additionalProperties: false, + }; } if (!resolveRefs) { if (ref.startsWith("#/components/schemas/")) { @@ -460,12 +562,10 @@ export class OpenAPIToMCPConverter { ); if (bodySchema.type === "object" && bodySchema.properties) { for (const [name, propSchema] of Object.entries(bodySchema.properties)) { - // TODO: Add support for filters - if (name === "filters") continue; schema.properties![name] = propSchema; } if (bodySchema.required) { - schema.required!.push(...bodySchema.required.filter((r) => r !== "filters")); + schema.required!.push(...bodySchema.required); } } } @@ -589,12 +689,10 @@ export class OpenAPIToMCPConverter { ); if (formSchema.type === "object" && formSchema.properties) { for (const [name, propSchema] of Object.entries(formSchema.properties)) { - // TODO: Add support for filters - if (name === "filters") continue; inputSchema.properties![name] = propSchema; } if (formSchema.required) { - inputSchema.required!.push(...formSchema.required!.filter((r) => r !== "filters")); + inputSchema.required!.push(...formSchema.required!); } } } @@ -608,12 +706,10 @@ export class OpenAPIToMCPConverter { // Merge body schema into the inputSchema's properties if (bodySchema.type === "object" && bodySchema.properties) { for (const [name, propSchema] of Object.entries(bodySchema.properties)) { - // TODO: Add support for filters - if (name === "filters") continue; inputSchema.properties![name] = propSchema; } if (bodySchema.required) { - inputSchema.required!.push(...bodySchema.required!.filter((r) => r !== "filters")); + inputSchema.required!.push(...bodySchema.required!); } } else { // If the request body is not an object, just put it under "body"