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