Skip to main content
Glama
r-huijts

OpenTK Model Context Protocol Server

by r-huijts

search_tk_filtered

Refine search queries within Dutch parliamentary data by filtering results to specific content types such as documents, activities, or cases. Use advanced syntax for exact phrases, exclusions, alternatives, or proximity searches to obtain precise results.

Instructions

Performs a targeted search within a specific category of parliamentary data. Unlike the general search, this tool allows you to limit results to only documents, activities, or cases. Use this when you need more focused search results within a particular content type. Search syntax: Searching for 'Joe Biden' finds documents containing both 'Joe' and 'Biden' anywhere in the text. Searching for "Joe Biden" (with quotes) finds only documents where these words appear next to each other. Searching for 'Hubert NOT Bruls' finds documents containing 'Hubert' but not 'Bruls'. The capital letters in 'NOT' are important. You can also use 'OR' and 'NEAR()' operators.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
formatNoFormat of the results: 'full' for complete data or 'summary' for a condensed version (default: 'summary')
limitNoMaximum number of results to return per page (default: 20, max: 100)
pageNoPage number for paginated results (default: 1)
queryYesSearch term - any keyword, name, policy area, or quote you want to find in parliamentary records. Use quotes for exact phrases, 'NOT' to exclude terms, 'OR' for alternatives, and 'NEAR()' for proximity searches.
typeYesCategory filter: 'Document' for official papers, reports and letters; 'Activiteit' for debates and committee meetings; 'Zaak' for legislative cases and motions

Implementation Reference

  • src/index.ts:209-315 (registration)
    Registration of the 'search_tk_filtered' tool using mcp.tool(), including description, input schema with Zod, and inline handler function.
    /** 7. Search filtered by type */
    mcp.tool(
      "search_tk_filtered",
      "Performs a search within a specific category of parliamentary data, allowing results to be limited to only documents, activities, or cases. Returns paginated results sorted by date (most recent first). Search syntax supports: keyword searches ('Joe Biden' finds both terms), exact phrase searches (\"Joe Biden\" with quotes finds the exact phrase), exclusion ('Hubert NOT Bruls' excludes documents with 'Bruls'), and boolean operators ('OR', 'NEAR()'). Results can be returned in 'full' or 'summary' format.",
      {
        query: z.string().describe("Search term - any keyword, name, policy area, or quote you want to find in parliamentary records. Use quotes for exact phrases, 'NOT' to exclude terms, 'OR' for alternatives, and 'NEAR()' for proximity searches."),
        type: z
          .enum(["Document", "Activiteit", "Zaak"])
          .describe("Category filter: 'Document' for official papers, reports and letters; 'Activiteit' for debates and committee meetings; 'Zaak' for legislative cases and motions"),
        page: z.number().optional().describe("Page number for paginated results (default: 1)"),
        limit: z.number().optional().describe("Maximum number of results to return per page (default: 20, max: 100)"),
        format: z.enum(["full", "summary"]).optional().describe("Format of the results: 'full' for complete data or 'summary' for a condensed version (default: 'summary')")
      },
      async ({ query, type, page = 1, limit = 20, format = "summary" }) => {
        try {
          // Validate and cap the limit
          const validatedLimit = Math.min(Math.max(1, limit), 100);
          const validatedPage = Math.max(1, page);
    
          const data = await apiService.search<{ results: any[], error?: string }>(query);
    
          // Check if there's an error message in the response
          if (data.error) {
            return {
              content: [
                { type: "text", text: data.error },
                { type: "text", text: "[]" }
              ]
            };
          }
    
          // Filter the results by category
          const filtered = data.results ? data.results.filter((r: any) => r.category === type) : [];
    
          // If no results were found after filtering
          if (filtered.length === 0) {
            return {
              content: [{
                type: "text",
                text: `No results found for query: ${query} with filter: ${type}. Try using different keywords or a different filter.`
              }]
            };
          }
    
          // Sort filtered results by date (most recent first)
          const sortedResults = [...filtered].sort((a, b) => {
            // Parse dates from the 'datum' field (format: YYYY-MM-DDT00:00:00)
            const dateA = new Date(a.datum);
            const dateB = new Date(b.datum);
            return dateB.getTime() - dateA.getTime(); // Descending order (newest first)
          });
    
          // Calculate pagination
          const totalResults = sortedResults.length;
          const totalPages = Math.ceil(totalResults / validatedLimit);
          const startIndex = (validatedPage - 1) * validatedLimit;
          const endIndex = Math.min(startIndex + validatedLimit, totalResults);
          const paginatedResults = sortedResults.slice(startIndex, endIndex);
    
          // Create pagination info
          const paginationInfo = {
            query,
            type,
            totalResults,
            page: validatedPage,
            limit: validatedLimit,
            totalPages,
            hasNextPage: validatedPage < totalPages,
            hasPreviousPage: validatedPage > 1
          };
    
          // Format the results based on the requested format
          let formattedResults;
          if (format === "summary") {
            // Create a summary version with only essential fields
            formattedResults = paginatedResults.map(item => ({
              id: item.id,
              title: item.title,
              category: item.category,
              datum: item.datum,
              url: item.url
            }));
          } else {
            // Use the full data
            formattedResults = paginatedResults;
          }
    
          // Return the paginated results with pagination info
          return {
            content: [{
              type: "text",
              text: JSON.stringify({
                pagination: paginationInfo,
                results: formattedResults
              }, null, 2)
            }]
          };
        } catch (error: any) {
          return {
            content: [{
              type: "text",
              text: `Error searching with filter: ${error.message || 'Unknown error'}`
            }]
          };
        }
      }
    );
  • Handler function that performs the search using apiService.search, filters results by the specified 'type' category, sorts by date (newest first), paginates, formats as 'summary' or 'full', and returns JSON-structured text content.
    async ({ query, type, page = 1, limit = 20, format = "summary" }) => {
      try {
        // Validate and cap the limit
        const validatedLimit = Math.min(Math.max(1, limit), 100);
        const validatedPage = Math.max(1, page);
    
        const data = await apiService.search<{ results: any[], error?: string }>(query);
    
        // Check if there's an error message in the response
        if (data.error) {
          return {
            content: [
              { type: "text", text: data.error },
              { type: "text", text: "[]" }
            ]
          };
        }
    
        // Filter the results by category
        const filtered = data.results ? data.results.filter((r: any) => r.category === type) : [];
    
        // If no results were found after filtering
        if (filtered.length === 0) {
          return {
            content: [{
              type: "text",
              text: `No results found for query: ${query} with filter: ${type}. Try using different keywords or a different filter.`
            }]
          };
        }
    
        // Sort filtered results by date (most recent first)
        const sortedResults = [...filtered].sort((a, b) => {
          // Parse dates from the 'datum' field (format: YYYY-MM-DDT00:00:00)
          const dateA = new Date(a.datum);
          const dateB = new Date(b.datum);
          return dateB.getTime() - dateA.getTime(); // Descending order (newest first)
        });
    
        // Calculate pagination
        const totalResults = sortedResults.length;
        const totalPages = Math.ceil(totalResults / validatedLimit);
        const startIndex = (validatedPage - 1) * validatedLimit;
        const endIndex = Math.min(startIndex + validatedLimit, totalResults);
        const paginatedResults = sortedResults.slice(startIndex, endIndex);
    
        // Create pagination info
        const paginationInfo = {
          query,
          type,
          totalResults,
          page: validatedPage,
          limit: validatedLimit,
          totalPages,
          hasNextPage: validatedPage < totalPages,
          hasPreviousPage: validatedPage > 1
        };
    
        // Format the results based on the requested format
        let formattedResults;
        if (format === "summary") {
          // Create a summary version with only essential fields
          formattedResults = paginatedResults.map(item => ({
            id: item.id,
            title: item.title,
            category: item.category,
            datum: item.datum,
            url: item.url
          }));
        } else {
          // Use the full data
          formattedResults = paginatedResults;
        }
    
        // Return the paginated results with pagination info
        return {
          content: [{
            type: "text",
            text: JSON.stringify({
              pagination: paginationInfo,
              results: formattedResults
            }, null, 2)
          }]
        };
      } catch (error: any) {
        return {
          content: [{
            type: "text",
            text: `Error searching with filter: ${error.message || 'Unknown error'}`
          }]
        };
      }
    }
  • Input schema defined using Zod validators for query, type (enum), page, limit, format parameters.
    {
      query: z.string().describe("Search term - any keyword, name, policy area, or quote you want to find in parliamentary records. Use quotes for exact phrases, 'NOT' to exclude terms, 'OR' for alternatives, and 'NEAR()' for proximity searches."),
      type: z
        .enum(["Document", "Activiteit", "Zaak"])
        .describe("Category filter: 'Document' for official papers, reports and letters; 'Activiteit' for debates and committee meetings; 'Zaak' for legislative cases and motions"),
      page: z.number().optional().describe("Page number for paginated results (default: 1)"),
      limit: z.number().optional().describe("Maximum number of results to return per page (default: 20, max: 100)"),
      format: z.enum(["full", "summary"]).optional().describe("Format of the results: 'full' for complete data or 'summary' for a condensed version (default: 'summary')")
    },
  • apiService.search method called by the handler to perform the actual API search request, which the handler then filters client-side by category.
    async search<T>(query: string, options: { twomonths?: boolean, soorten?: string } = {}): Promise<T> {
      try {
        // Don't sanitize quotes as they're important for exact phrase searches
        // Only sanitize backslashes which could cause issues
        const sanitizedQuery = query.replace(/\\/g, ' ').trim();
    
        // Use FormData for multipart/form-data
        const formData = new FormData();
        formData.append('q', sanitizedQuery);
        formData.append('twomonths', options.twomonths ? "true" : "false");
        formData.append('soorten', options.soorten || "alles"); // Default to 'alles'
    
        const res = await fetch(`${BASE_URL}/search`, {
          method: "POST",
          headers: {
            // Remove explicit Content-Type, fetch will set it for FormData
            'Accept': '*/*',
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
            'Referer': `${BASE_URL}/search.html?q=${encodeURIComponent(sanitizedQuery)}&twomonths=${options.twomonths ? "true" : "false"}&soorten=${options.soorten || "alles"}`,
            'Origin': BASE_URL,
            'Host': 'berthub.eu',
            'Connection': 'keep-alive',
            'Sec-Fetch-Dest': 'empty',
            'Sec-Fetch-Mode': 'cors',
            'Sec-Fetch-Site': 'same-origin',
            'sec-ch-ua': '"Chromium";v="135", "Not-A.Brand";v="8"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"macOS"'
          },
          body: formData, // Pass FormData object directly
          agent: ApiService.agent
        } as NodeRequestInit);
    
        if (!res.ok) {
          // If we get a 500 error, try to use a simplified query instead
          if (res.status === 500) {
            // API returned 500 error - let's investigate before simplifying
            let actualErrorContent = await res.text().catch(() => "Could not read error response body");
            let errorMessage = `Initial request failed with 500: ${res.statusText}. Response: ${actualErrorContent.substring(0, 200)}...`; // Limit length
    
            // Optional: Try simplifying only if the error content suggests it,
            // otherwise, maybe just report the initial 500 error.
            // For now, we'll still try simplifying but provide better final message.
    
            const simplifiedQuery = sanitizedQuery.split(/\\s+/)[0];
    
            // Initialize retry error message
            let retryErrorMessage = "Simplification/Retry not attempted or failed.";
    
            if (simplifiedQuery && simplifiedQuery !== sanitizedQuery) {
              // Retry with simplified query
    
              // Use FormData for the retry as well
              const simplifiedFormData = new FormData();
              simplifiedFormData.append('q', simplifiedQuery);
              simplifiedFormData.append('twomonths', options.twomonths ? "true" : "false");
              simplifiedFormData.append('soorten', options.soorten || "alles"); // Default to 'alles'
    
              try {
                const retryRes = await fetch(`${BASE_URL}/search`, {
                  method: "POST",
                  headers: { // Use same headers as initial, without Content-Type
                    'Accept': '*/*',
                    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
                    'Referer': `${BASE_URL}/search.html?q=${encodeURIComponent(simplifiedQuery)}&twomonths=${options.twomonths ? "true" : "false"}&soorten=${options.soorten || "alles"}`,
                    'Origin': BASE_URL,
                    'Host': 'berthub.eu',
                    'Connection': 'keep-alive',
                    'Sec-Fetch-Dest': 'empty',
                    'Sec-Fetch-Mode': 'cors',
                    'Sec-Fetch-Site': 'same-origin',
                    'sec-ch-ua': '"Chromium";v="135", "Not-A.Brand";v="8"',
                    'sec-ch-ua-mobile': '?0',
                    'sec-ch-ua-platform': '"macOS"'
                  },
                  body: simplifiedFormData, // Pass FormData object
                  agent: ApiService.agent
                } as NodeRequestInit);
    
                if (retryRes.ok) {
                  const retryText = await retryRes.text();
                  // Check for HTML in retry response too
                  if (retryText.trim().startsWith('<!DOCTYPE')) {
                     retryErrorMessage = "Retry attempt returned HTML instead of JSON.";
                  } else {
                    try {
                      const retryData = JSON.parse(retryText);
                      // Return only the parsed data, matching the expected type T
                      return retryData as T;
                    } catch (e) {
                      retryErrorMessage = `Retry succeeded but failed to parse JSON response: ${e instanceof Error ? e.message : String(e)}. Response: ${retryText.substring(0, 200)}...`;
                    }
                  }
                } else {
                  // Capture retry failure details
                  const retryErrorBody = await retryRes.text().catch(() => "Could not read retry error body");
                  retryErrorMessage = `Retry with simplified query failed with status: ${retryRes.status} ${retryRes.statusText}. Response: ${retryErrorBody.substring(0, 200)}...`;
                }
              } catch (retryFetchError) {
                   retryErrorMessage = `Fetch error during retry attempt: ${retryFetchError instanceof Error ? retryFetchError.message : String(retryFetchError)}`;
              }
            } else {
               retryErrorMessage = "Original query was already simple; retry not attempted.";
            }
    
            // If retry failed or wasn't applicable, return empty results with a more informative error
            // Combine initial error reason and retry outcome.
            const finalErrorMessage = `Search failed for query '${sanitizedQuery}'. Initial error: ${res.status} ${res.statusText}. ${retryErrorMessage}`;
            return {
              results: [],
              error: finalErrorMessage
            } as T;
          }
          // Handle non-500 errors
          const errorBody = await res.text().catch(() => "Could not read error response body");
          throw new Error(`API error: ${res.status} ${res.statusText}. Response: ${errorBody.substring(0, 200)}...`);
        }
    
        // Check if the response is HTML
        const text = await res.text();
        if (text.trim().startsWith('<!DOCTYPE')) {
          // The API returned HTML instead of JSON
          return { results: [] } as T;
        }
    
        // Parse JSON
        try {
          return JSON.parse(text) as T;
        } catch (error) {
          // Failed to parse JSON
          return { results: [] } as T;
        }
      } catch (error) {
        // Unexpected error in search
        throw error;
      }
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden of behavioral disclosure. It effectively describes search syntax rules (quotes, NOT, OR, NEAR operators), which are critical behavioral traits not covered by the schema. However, it doesn't mention pagination behavior, rate limits, or authentication requirements, leaving some gaps.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is well-structured and appropriately sized. It front-loads the purpose and usage guidelines, then provides detailed search syntax. While comprehensive, some syntax examples could be more concise, but overall it earns its place with valuable information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a search tool with no annotations and no output schema, the description provides good context about behavior and usage. It covers search syntax thoroughly and distinguishes from siblings. The main gap is lack of information about return format or result structure, which would be helpful given no output schema exists.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all parameters thoroughly. The description adds minimal value beyond the schema - it mentions 'category filter' for the 'type' parameter but doesn't provide additional semantic context. The baseline of 3 is appropriate when the schema does the heavy lifting.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool 'performs a targeted search within a specific category of parliamentary data' and distinguishes it from the general search by allowing limitation to documents, activities, or cases. It explicitly mentions the sibling tool 'search_tk' (general search) in the first sentence, providing clear differentiation.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides explicit guidance: 'Use this when you need more focused search results within a particular content type.' It contrasts with the general search tool and specifies the appropriate context for usage, making it clear when to choose this over alternatives.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Related Tools

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/r-huijts/opentk-mcp'

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