Skip to main content
Glama
r-huijts

OpenTK Model Context Protocol Server

by r-huijts

search_tk

Perform precise searches across Dutch parliamentary records, including documents, debates, and member information. Filter results using keywords, exact phrases, or advanced operators like 'NOT', 'OR', and 'NEAR()' for accurate topic tracking.

Instructions

Performs a comprehensive search across all parliamentary data including documents, activities, and cases. Returns results matching the provided keyword or phrase. Use this for general searches when you need information on any topic discussed in parliament, regardless of document type or context. 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 keyword or phrase - can be any term, name, policy area, or exact 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.

Implementation Reference

  • src/index.ts:108-207 (registration)
    Registration of the 'search_tk' MCP tool. Includes tool name, description, input schema (Zod validation for query, page, limit, format parameters), and complete inline async handler function that performs search via apiService.search, sorts results by date, applies pagination, formats output based on 'summary' or 'full', and handles errors.
    /** 6. Keyword search */
    mcp.tool(
      "search_tk",
      "Performs a comprehensive search across all parliamentary data including documents, activities, and cases. Returns results matching the provided keyword or phrase. Use this for general searches when you need information on any topic discussed in parliament, regardless of document type or context. 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.",
      {
        query: z.string().describe("Search keyword or phrase - can be any term, name, policy area, or exact 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."),
        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, 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: JSON.stringify(data.results, null, 2) }
              ]
            };
          }
    
          // If no results were found
          if (!data.results || data.results.length === 0) {
            return {
              content: [{
                type: "text",
                text: `No results found for query: ${query}. Try using different keywords or simplifying your search.`
              }]
            };
          }
    
          // Sort results by date (most recent first)
          const sortedResults = [...data.results].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,
            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: ${error.message || 'Unknown error'}`
            }]
          };
        }
      }
    );
  • The main handler function for 'search_tk' tool execution. Fetches search results from apiService.search, sorts by recency, paginates, optionally summarizes results, and structures response with pagination metadata.
    async ({ query, 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: JSON.stringify(data.results, null, 2) }
            ]
          };
        }
    
        // If no results were found
        if (!data.results || data.results.length === 0) {
          return {
            content: [{
              type: "text",
              text: `No results found for query: ${query}. Try using different keywords or simplifying your search.`
            }]
          };
        }
    
        // Sort results by date (most recent first)
        const sortedResults = [...data.results].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,
          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: ${error.message || 'Unknown error'}`
          }]
        };
      }
    }
  • Input schema using Zod for 'search_tk' tool parameters: required 'query' string, optional 'page' (default 1), 'limit' (default 20, max 100), 'format' enum ["full","summary"] (default "summary").
    {
      query: z.string().describe("Search keyword or phrase - can be any term, name, policy area, or exact 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."),
      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')")
  • Core helper function apiService.search() that implements the HTTP POST request to the tkconv /search endpoint using FormData. Supports advanced search syntax preservation, automatic retry on server 500 errors with simplified query, robust error handling, and JSON parsing. Called by the 'search_tk' handler.
    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;
      }
    }

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