Skip to main content
Glama

firewalla-mcp-server

parser.ts15.3 kB
/** * Advanced Query Parser for Firewalla Search API * Implements recursive descent parser for complex search queries */ import { TokenType, SEARCH_FIELDS, type QueryNode, type FieldQuery, type LogicalQuery, type GroupQuery, type TokenTypeValue, type WildcardQuery, type RangeQuery, type ComparisonQuery, type Token, type QueryValidation, } from './types.js'; export class QueryParser { private tokens: Token[] = []; private current = 0; private errors: string[] = []; /** * Parse a search query string into an AST */ parse( query: string, entityType?: keyof typeof SEARCH_FIELDS ): QueryValidation { this.reset(); // Input validation if (!query || typeof query !== 'string') { this.errors.push('Query must be a non-empty string'); return { isValid: false, errors: this.errors, warnings: [], suggestions: [], ast: undefined, }; } try { this.tokens = this.tokenize(query); const ast = this.parseExpression(); // Validate fields if entity type is provided if (entityType && ast) { this.validateFields(ast, entityType); } return { isValid: this.errors.length === 0, errors: this.errors, warnings: [], suggestions: this.generateSuggestions(query, entityType), ast: this.errors.length === 0 ? ast : undefined, }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown parsing error'; this.errors.push(errorMsg); return { isValid: false, errors: this.errors, warnings: [], suggestions: this.generateSuggestions(query, entityType), }; } } private reset(): void { this.tokens = []; this.current = 0; this.errors = []; } /** * Tokenize the input query string */ private tokenize(input: string): Token[] { const tokens: Token[] = []; let i = 0; // Additional safety check if (!input || typeof input !== 'string') { return tokens; } const safeInput = input.trim(); if (!safeInput) { return tokens; } while (i < safeInput.length) { const char = safeInput[i]; // Skip whitespace if (/\s/.test(char)) { i++; continue; } // Parentheses if (char === '(') { tokens.push({ type: TokenType.LPAREN, value: char, position: i, length: 1, }); i++; continue; } if (char === ')') { tokens.push({ type: TokenType.RPAREN, value: char, position: i, length: 1, }); i++; continue; } // Brackets for ranges if (char === '[') { tokens.push({ type: TokenType.LBRACKET, value: char, position: i, length: 1, }); i++; continue; } if (char === ']') { tokens.push({ type: TokenType.RBRACKET, value: char, position: i, length: 1, }); i++; continue; } // Colon for field:value if (char === ':') { tokens.push({ type: TokenType.COLON, value: char, position: i, length: 1, }); i++; continue; } // Quoted strings if (char === '"' || char === "'") { const quote = char; let value = ''; i++; // Skip opening quote const start = i - 1; while (i < safeInput.length && safeInput[i] !== quote) { if (safeInput[i] === '\\' && i + 1 < safeInput.length) { // Handle escaped characters i++; value += safeInput[i]; } else { value += safeInput[i]; } i++; } if (i >= safeInput.length) { throw new Error( `Unclosed quoted string starting at position ${start}` ); } i++; // Skip closing quote tokens.push({ type: TokenType.QUOTED_VALUE, value, position: start, length: i - start, }); continue; } // Operators if (char === '>' || char === '<') { let operator = char; i++; if (i < safeInput.length && safeInput[i] === '=') { operator += '='; i++; } tokens.push({ type: TokenType.OPERATOR, value: operator, position: i - operator.length, length: operator.length, }); continue; } if ( char === '!' && i + 1 < safeInput.length && safeInput[i + 1] === '=' ) { tokens.push({ type: TokenType.OPERATOR, value: '!=', position: i, length: 2, }); i += 2; continue; } // Words (fields, values, logical operators) if (/[a-zA-Z_]/.test(char)) { let word = ''; const start = i; while (i < safeInput.length && /[a-zA-Z0-9_.-]/.test(safeInput[i])) { word += safeInput[i]; i++; } const upperWord = word.toUpperCase(); if (upperWord === 'AND' || upperWord === 'OR' || upperWord === 'NOT') { tokens.push({ type: TokenType.LOGICAL, value: upperWord, position: start, length: word.length, }); } else if (upperWord === 'TO') { tokens.push({ type: TokenType.TO, value: upperWord, position: start, length: word.length, }); } else { tokens.push({ type: TokenType.FIELD, value: word, position: start, length: word.length, }); } continue; } // Numbers and values with wildcards if (/[0-9*?]/.test(char) || char === '.') { let value = ''; const start = i; let hasWildcard = false; while (i < safeInput.length && /[0-9*?.-]/.test(safeInput[i])) { if (safeInput[i] === '*' || safeInput[i] === '?') { hasWildcard = true; } value += safeInput[i]; i++; } tokens.push({ type: hasWildcard ? TokenType.WILDCARD : TokenType.VALUE, value, position: start, length: value.length, }); continue; } // Unknown character throw new Error(`Unexpected character '${char}' at position ${i}`); } tokens.push({ type: TokenType.EOF, value: '', position: safeInput.length, length: 0, }); return tokens; } /** * Parse expression with logical operators (lowest precedence) */ private parseExpression(): QueryNode | undefined { let left = this.parseAndExpression(); while (this.match(TokenType.LOGICAL) && this.previous().value === 'OR') { const right = this.parseAndExpression(); if (!right) { break; } left = { type: 'logical', operator: 'OR', left, right, } as LogicalQuery; } return left; } /** * Parse AND expressions (higher precedence than OR) */ private parseAndExpression(): QueryNode | undefined { let left = this.parseNotExpression(); while (this.match(TokenType.LOGICAL) && this.previous().value === 'AND') { const right = this.parseNotExpression(); if (!right) { break; } left = { type: 'logical', operator: 'AND', left, right, } as LogicalQuery; } return left; } /** * Parse NOT expressions (highest precedence) */ private parseNotExpression(): QueryNode | undefined { if (this.match(TokenType.LOGICAL) && this.previous().value === 'NOT') { const operand = this.parsePrimary(); if (!operand) { this.errors.push('Expected expression after NOT operator'); return undefined; } return { type: 'logical', operator: 'NOT', operand, } as LogicalQuery; } return this.parsePrimary(); } /** * Parse primary expressions (field queries, groups, etc.) */ private parsePrimary(): QueryNode | undefined { // Grouped expression if (this.match(TokenType.LPAREN)) { const expr = this.parseExpression(); if (!this.match(TokenType.RPAREN)) { this.errors.push('Expected closing parenthesis'); return undefined; } return expr ? ({ type: 'group', query: expr } as GroupQuery) : undefined; } // Field query if (this.check(TokenType.FIELD)) { return this.parseFieldQuery(); } // Wildcard query (standalone *) - treat as match-all if (this.match(TokenType.WILDCARD) && this.previous().value === '*') { // Return a special match-all query that bypasses field validation return { type: 'field', field: '*', value: '*', } as FieldQuery; } this.errors.push(`Unexpected token: ${this.peek().value}`); return undefined; } /** * Parse field-based queries (field:value, field:>value, etc.) */ private parseFieldQuery(): QueryNode | undefined { const fieldToken = this.advance(); const field = fieldToken.value; if (!this.match(TokenType.COLON)) { this.errors.push(`Expected ':' after field '${field}'`); return undefined; } // Check for operators if (this.match(TokenType.OPERATOR)) { const operator = this.previous().value as '>=' | '<=' | '>' | '<' | '!='; if (this.match(TokenType.VALUE, TokenType.QUOTED_VALUE)) { const { value } = this.previous(); if ( operator === '>=' || operator === '<=' || operator === '>' || operator === '<' ) { return { type: 'comparison', field, operator, value: this.parseValue(value), } as ComparisonQuery; } return { type: 'field', field, value, operator: operator as '!=', } as FieldQuery; } } // Range query [min TO max] if (this.match(TokenType.LBRACKET)) { return this.parseRangeQuery(field); } // Wildcard or regular value if (this.match(TokenType.WILDCARD)) { const pattern = this.previous().value; return { type: 'wildcard', field, pattern, } as WildcardQuery; } if (this.match(TokenType.VALUE, TokenType.QUOTED_VALUE, TokenType.FIELD)) { const { value } = this.previous(); return { type: 'field', field, value, operator: '=', } as FieldQuery; } this.errors.push(`Expected value after field '${field}:'`); return undefined; } /** * Parse range queries [min TO max] */ private parseRangeQuery(field: string): RangeQuery | undefined { let min: string | number | undefined; let max: string | number | undefined; // Parse minimum value if (this.match(TokenType.VALUE, TokenType.QUOTED_VALUE)) { min = this.parseValue(this.previous().value); } if (!this.match(TokenType.TO)) { this.errors.push('Expected TO in range query'); return undefined; } // Parse maximum value if (this.match(TokenType.VALUE, TokenType.QUOTED_VALUE)) { max = this.parseValue(this.previous().value); } if (!this.match(TokenType.RBRACKET)) { this.errors.push('Expected closing bracket in range query'); return undefined; } return { type: 'range', field, min, max, inclusive: true, }; } /** * Parse and convert values to appropriate types */ private parseValue(value: string): string | number { // Check if value is an integer if (/^-?\d+$/.test(value)) { const int = parseInt(value, 10); if (Number.isSafeInteger(int)) { return int; } } else if (/^-?\d+\.\d+$/.test(value)) { // Handle float values const float = parseFloat(value); if (!isNaN(float) && Number.isFinite(float)) { return float; } } return value; } /** * Validate fields against entity schema */ private validateFields( node: QueryNode, entityType: keyof typeof SEARCH_FIELDS ): void { const validFields = SEARCH_FIELDS[entityType]; const validateNode = (n: QueryNode): void => { switch (n.type) { case 'field': case 'wildcard': case 'range': case 'comparison': { const fieldNode = n; // Skip validation for special match-all field '*' if ( fieldNode.field !== '*' && !validFields.includes(fieldNode.field) ) { this.errors.push( `Invalid field '${fieldNode.field}' for ${entityType}. Valid fields: ${validFields.join(', ')}` ); } break; } case 'logical': if (n.left) { validateNode(n.left); } if (n.right) { validateNode(n.right); } if (n.operand) { validateNode(n.operand); } break; case 'group': validateNode(n.query); break; } }; validateNode(node); } /** * Generate helpful suggestions for invalid queries */ private generateSuggestions( query: string, entityType?: keyof typeof SEARCH_FIELDS ): string[] { const suggestions: string[] = []; if (entityType && this.errors.some(e => e.includes('Invalid field'))) { suggestions.push( `Available fields for ${entityType}: ${SEARCH_FIELDS[entityType].join(', ')}` ); } if (query.includes('(') && !query.includes(')')) { suggestions.push('Check for missing closing parenthesis'); } if (query.includes('[') && !query.includes(']')) { suggestions.push('Check for missing closing bracket in range query'); } if (query.includes(':') && !query.split(':').every(part => part.trim())) { suggestions.push('Ensure field:value pairs are properly formatted'); } return suggestions; } // Utility methods for token management private match(...types: TokenTypeValue[]): boolean { for (const type of types) { if (this.check(type)) { this.advance(); return true; } } return false; } private check(type: TokenTypeValue): boolean { if (this.isAtEnd()) { return false; } return this.peek().type === type; } private advance(): Token { if (!this.isAtEnd()) { this.current++; } return this.previous(); } private isAtEnd(): boolean { return this.peek().type === TokenType.EOF; } private peek(): Token { return this.tokens[this.current]; } private previous(): Token { return this.tokens[this.current - 1]; } } // Export singleton instance export const queryParser = new QueryParser();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/amittell/firewalla-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server