Skip to main content
Glama
ahnmichael

GitLab Forum MCP

by ahnmichael

discourse_search

Search GitLab community forum content to find discussions and solutions for troubleshooting CI/CD issues and GitLab features.

Instructions

Search site content.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
queryYesSearch query
with_privateNo
max_resultsNo

Implementation Reference

  • The handler function that executes the discourse_search tool. It performs an API search on the Discourse site using the provided query, processes the results (topics and posts), formats a text response with links, and includes a JSON summary of results.
    async (args, _extra: any) => {
      const { query, with_private = false, max_results = 10 } = args;
      const { base, client } = ctx.siteState.ensureSelectedSite();
      const q = new URLSearchParams();
      q.set("expanded", "true");
      const fullQuery = ctx.defaultSearchPrefix ? `${ctx.defaultSearchPrefix} ${query}` : query;
      q.set("q", fullQuery);
    
      // Debug logging
      ctx.logger?.debug(`Search query: "${query}"`);
      ctx.logger?.debug(`Full query with prefix: "${fullQuery}"`);
      ctx.logger?.debug(`Search URL: ${base}/search.json?${q.toString()}`);
    
      try {
        const data = (await client.get(`/search.json?${q.toString()}`)) as any;
    
        // Debug the response structure
        ctx.logger?.debug(`Search response keys: ${Object.keys(data || {}).join(', ')}`);
        ctx.logger?.debug(`Topics found: ${data?.topics?.length || 0}`);
        ctx.logger?.debug(`Posts found: ${data?.posts?.length || 0}`);
    
        const topics: any[] = data?.topics || [];
        const posts: any[] = data?.posts || [];
    
        // If no topics but we have posts, we can extract topic info from posts
        let items = topics.map((t) => ({
          type: "topic" as const,
          id: t.id,
          title: t.title || t.fancy_title || `Topic ${t.id}`,
          slug: t.slug || String(t.id),
        })) as Array<{ type: "topic"; id: number; title: string; slug: string }>;
    
        // If we don't have enough topics, supplement with unique topics from posts
        if (items.length < max_results && posts.length > 0) {
          const existingTopicIds = new Set(items.map(t => t.id));
          const postTopics = posts
            .filter(p => p.topic_id && !existingTopicIds.has(p.topic_id))
            .map(p => ({
              type: "topic" as const,
              id: p.topic_id,
              title: `Post topic ${p.topic_id}`, // We don't have the topic title from post
              slug: String(p.topic_id),
            }));
    
          // Add unique post topics up to our limit
          const seenIds = new Set();
          for (const pt of postTopics) {
            if (items.length >= max_results) break;
            if (!seenIds.has(pt.id)) {
              seenIds.add(pt.id);
              items.push(pt);
            }
          }
        }
    
        items = items.slice(0, max_results);
    
        const lines: string[] = [];
        if (items.length === 0) {
          lines.push(`No results found for "${query}"`);
          // Add some debug info about what we got
          if (posts.length > 0) {
            lines.push(`Found ${posts.length} posts but no direct topics`);
          }
        } else {
          lines.push(`Top results for "${query}":`);
          let idx = 1;
          for (const it of items) {
            const url = `${base}/t/${it.slug}/${it.id}`;
            lines.push(`${idx}. ${it.title} – ${url}`);
            idx++;
          }
        }
    
        const jsonFooter = {
          results: items.map((it) => ({ id: it.id, url: `${base}/t/${it.slug}/${it.id}`, title: it.title })),
        };
        const text = lines.join("\n") + "\n\n```json\n" + JSON.stringify(jsonFooter) + "\n```\n";
        return { content: [{ type: "text", text }] };
      } catch (e: any) {
        ctx.logger?.error(`Search failed: ${e?.message || String(e)}`);
        return { content: [{ type: "text", text: `Search failed: ${e?.message || String(e)}` }], isError: true };
      }
    }
  • Input schema for the discourse_search tool using Zod, validating query (required string), optional with_private boolean, and optional max_results (1-50).
    const schema = z.object({
      query: z.string().min(1).describe("Search query"),
      with_private: z.boolean().optional(),
      max_results: z.number().int().min(1).max(50).optional(),
    });
  • Registers the discourse_search tool with the MCP server inside the registerSearch function, specifying title, description, input schema, and handler.
    server.registerTool(
      "discourse_search",
      {
        title: "Discourse Search",
        description: "Search site content.",
        inputSchema: schema.shape,
      },
      async (args, _extra: any) => {
        const { query, with_private = false, max_results = 10 } = args;
        const { base, client } = ctx.siteState.ensureSelectedSite();
        const q = new URLSearchParams();
        q.set("expanded", "true");
        const fullQuery = ctx.defaultSearchPrefix ? `${ctx.defaultSearchPrefix} ${query}` : query;
        q.set("q", fullQuery);
    
        // Debug logging
        ctx.logger?.debug(`Search query: "${query}"`);
        ctx.logger?.debug(`Full query with prefix: "${fullQuery}"`);
        ctx.logger?.debug(`Search URL: ${base}/search.json?${q.toString()}`);
    
        try {
          const data = (await client.get(`/search.json?${q.toString()}`)) as any;
    
          // Debug the response structure
          ctx.logger?.debug(`Search response keys: ${Object.keys(data || {}).join(', ')}`);
          ctx.logger?.debug(`Topics found: ${data?.topics?.length || 0}`);
          ctx.logger?.debug(`Posts found: ${data?.posts?.length || 0}`);
    
          const topics: any[] = data?.topics || [];
          const posts: any[] = data?.posts || [];
    
          // If no topics but we have posts, we can extract topic info from posts
          let items = topics.map((t) => ({
            type: "topic" as const,
            id: t.id,
            title: t.title || t.fancy_title || `Topic ${t.id}`,
            slug: t.slug || String(t.id),
          })) as Array<{ type: "topic"; id: number; title: string; slug: string }>;
    
          // If we don't have enough topics, supplement with unique topics from posts
          if (items.length < max_results && posts.length > 0) {
            const existingTopicIds = new Set(items.map(t => t.id));
            const postTopics = posts
              .filter(p => p.topic_id && !existingTopicIds.has(p.topic_id))
              .map(p => ({
                type: "topic" as const,
                id: p.topic_id,
                title: `Post topic ${p.topic_id}`, // We don't have the topic title from post
                slug: String(p.topic_id),
              }));
    
            // Add unique post topics up to our limit
            const seenIds = new Set();
            for (const pt of postTopics) {
              if (items.length >= max_results) break;
              if (!seenIds.has(pt.id)) {
                seenIds.add(pt.id);
                items.push(pt);
              }
            }
          }
    
          items = items.slice(0, max_results);
    
          const lines: string[] = [];
          if (items.length === 0) {
            lines.push(`No results found for "${query}"`);
            // Add some debug info about what we got
            if (posts.length > 0) {
              lines.push(`Found ${posts.length} posts but no direct topics`);
            }
          } else {
            lines.push(`Top results for "${query}":`);
            let idx = 1;
            for (const it of items) {
              const url = `${base}/t/${it.slug}/${it.id}`;
              lines.push(`${idx}. ${it.title} – ${url}`);
              idx++;
            }
          }
    
          const jsonFooter = {
            results: items.map((it) => ({ id: it.id, url: `${base}/t/${it.slug}/${it.id}`, title: it.title })),
          };
          const text = lines.join("\n") + "\n\n```json\n" + JSON.stringify(jsonFooter) + "\n```\n";
          return { content: [{ type: "text", text }] };
        } catch (e: any) {
          ctx.logger?.error(`Search failed: ${e?.message || String(e)}`);
          return { content: [{ type: "text", text: `Search failed: ${e?.message || String(e)}` }], isError: true };
        }
      }
    );
  • Top-level call to registerSearch during the registration of all tools, effectively registering the discourse_search tool.
    registerSearch(server, ctx, { allowWrites: false });

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/ahnmichael/gitlab-forum-mcp'

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