Skip to main content
Glama
afrozk

MCP Job Toolkit

by afrozk

search_jobs

Search for jobs across Australian platforms (Seek, LinkedIn) using keywords, location, salary, employment type, and other filters.

Instructions

Search for jobs across Australian job platforms: Seek and LinkedIn. Supports keyword search, location with radius, employment type, salary, date posted, and application count filters.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
keywordYesJob title, skill, or keyword to search for (e.g. "software engineer", "data analyst")
locationYesAustralian city or suburb (e.g. "Melbourne", "Sydney CBD", "Brisbane QLD", "Perth")
radiusNoSearch radius in km from the location (default: 50)
employment_typeNoFilter by employment type. Multiple values allowed.
salary_typeNoSalary payment frequency for filtering
salary_minNoMinimum salary (in the currency unit matching salary_type, e.g. 80000 for yearly AUD)
salary_maxNoMaximum salary
date_postedNoOnly show jobs posted within this time window
max_applicationsNoOnly show jobs with fewer than this many applicants (e.g. 10, 20, 25). Not all platforms expose application count.
platformsNoWhich platforms to search (default: all)
pageNoPage number for pagination (default: 1)
per_pageNoResults per page per platform (default: 10, max: 25)

Implementation Reference

  • src/index.ts:24-98 (registration)
    Registration of the 'search_jobs' tool in the ListTools request handler, defining its name, description, and input JSON schema.
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'search_jobs',
          description:
            'Search for jobs across Australian job platforms: Seek and LinkedIn. Supports keyword search, location with radius, employment type, salary, date posted, and application count filters.',
          inputSchema: {
            type: 'object',
            required: ['keyword', 'location'],
            properties: {
              keyword: {
                type: 'string',
                description: 'Job title, skill, or keyword to search for (e.g. "software engineer", "data analyst")',
              },
              location: {
                type: 'string',
                description: 'Australian city or suburb (e.g. "Melbourne", "Sydney CBD", "Brisbane QLD", "Perth")',
              },
              radius: {
                type: 'number',
                description: 'Search radius in km from the location (default: 50)',
                default: 50,
                minimum: 5,
                maximum: 200,
              },
              employment_type: {
                type: 'array',
                description: 'Filter by employment type. Multiple values allowed.',
                items: { type: 'string', enum: ['fulltime', 'parttime', 'contract', 'casual'] },
              },
              salary_type: {
                type: 'string',
                description: 'Salary payment frequency for filtering',
                enum: ['hourly', 'monthly', 'yearly'],
              },
              salary_min: {
                type: 'number',
                description: 'Minimum salary (in the currency unit matching salary_type, e.g. 80000 for yearly AUD)',
              },
              salary_max: {
                type: 'number',
                description: 'Maximum salary',
              },
              date_posted: {
                type: 'string',
                description: 'Only show jobs posted within this time window',
                enum: ['24h', '2d', '3d', '7d', '30d'],
              },
              max_applications: {
                type: 'number',
                description: 'Only show jobs with fewer than this many applicants (e.g. 10, 20, 25). Not all platforms expose application count.',
              },
              platforms: {
                type: 'array',
                description: 'Which platforms to search (default: all)',
                items: { type: 'string', enum: ['seek', 'linkedin'] },
              },
              page: {
                type: 'number',
                description: 'Page number for pagination (default: 1)',
                default: 1,
                minimum: 1,
              },
              per_page: {
                type: 'number',
                description: 'Results per page per platform (default: 10, max: 25)',
                default: 10,
                minimum: 1,
                maximum: 25,
              },
            },
          },
        },
      ],
    }));
  • Handler for 'search_jobs' tool call. Parses arguments into JobSearchParams, dispatches to selected platform scrapers (seek/linkedin) via Promise.allSettled, and returns aggregated results.
    server.setRequestHandler(CallToolRequestSchema, async (request) => {
      if (request.params.name !== 'search_jobs') {
        return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
      }
    
      const args = request.params.arguments as Record<string, unknown>;
    
      const params: JobSearchParams = {
        keyword: String(args.keyword ?? ''),
        location: String(args.location ?? ''),
        radius: typeof args.radius === 'number' ? args.radius : 50,
        employment_type: Array.isArray(args.employment_type) ? args.employment_type as JobSearchParams['employment_type'] : undefined,
        salary_type: typeof args.salary_type === 'string' ? args.salary_type as JobSearchParams['salary_type'] : undefined,
        salary_min: typeof args.salary_min === 'number' ? args.salary_min : undefined,
        salary_max: typeof args.salary_max === 'number' ? args.salary_max : undefined,
        date_posted: typeof args.date_posted === 'string' ? args.date_posted as JobSearchParams['date_posted'] : undefined,
        max_applications: typeof args.max_applications === 'number' ? args.max_applications : undefined,
        platforms: Array.isArray(args.platforms) ? args.platforms as Platform[] : ALL_PLATFORMS,
        page: typeof args.page === 'number' ? args.page : 1,
        per_page: typeof args.per_page === 'number' ? Math.min(args.per_page, 25) : 10,
      };
    
      if (!params.keyword) {
        return { content: [{ type: 'text', text: 'keyword is required' }], isError: true };
      }
      if (!params.location) {
        return { content: [{ type: 'text', text: 'location is required' }], isError: true };
      }
    
      const targetPlatforms = params.platforms ?? ALL_PLATFORMS;
    
      const settled = await Promise.allSettled(
        targetPlatforms.map((p) => scraperMap[p](params))
      );
    
      const results = settled.map((s, i) => {
        if (s.status === 'fulfilled') return s.value;
        return {
          platform: targetPlatforms[i],
          jobs: [],
          page: params.page ?? 1,
          error: s.reason instanceof Error ? s.reason.message : String(s.reason),
        };
      });
    
      const response = {
        results,
        params,
        fetched_at: new Date().toISOString(),
        summary: results.map((r) => ({
          platform: r.platform,
          count: r.jobs.length,
          total: r.total,
          error: r.error,
        })),
      };
    
      return {
        content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
      };
    });
  • Type definition for JobSearchParams used as input to the search_jobs tool.
    export interface JobSearchParams {
      keyword: string;
      location: string;
      radius?: number; // default 50 (km)
      employment_type?: EmploymentType[];
      salary_type?: SalaryType;
      salary_min?: number;
      salary_max?: number;
      date_posted?: DatePosted;
      max_applications?: number;
      platforms?: Platform[];
      page?: number;
      per_page?: number; // default 10, max 25
    }
  • Seek platform scraper implementation - called by the handler to scrape jobs from au.seek.com using Apollo SSR cache parsing.
    export async function seekSearch(params: JobSearchParams): Promise<PlatformResult> {
      // Seek migrated from www.seek.com.au to au.seek.com.
      // The old /api/chalice-search endpoint is gone (404). Instead we hit the
      // search results page and parse the Apollo SSR cache embedded in the HTML.
      const client = createHttpClient({
        Referer: 'https://au.seek.com/',
        'Sec-Fetch-Dest': 'document',
        'Sec-Fetch-Mode': 'navigate',
        'Sec-Fetch-Site': 'none',
        'Sec-Fetch-User': '?1',
        'Upgrade-Insecure-Requests': '1',
      });
    
      const page = params.page ?? 1;
      const keywordSlug = slugify(params.keyword.toLowerCase());
      const locationSlug = slugify(params.location);
    
      const url = `https://au.seek.com/${keywordSlug}-jobs/in-${locationSlug}`;
    
      const query: Record<string, string | number> = { page };
      if (params.date_posted) query.daterange = DATE_RANGE_MAP[params.date_posted];
      if (params.employment_type?.length) {
        query.worktype = params.employment_type.map((t) => WORK_TYPE_MAP[t]).join(',');
      }
      if (params.salary_type) query.salarytype = SALARY_TYPE_MAP[params.salary_type];
      if (params.salary_min != null || params.salary_max != null) {
        const min = params.salary_min ?? 0;
        const max = params.salary_max ?? 999999;
        query.salaryrange = `${min}-${max}`;
      }
    
      try {
        const resp = await client.get(url, {
          params: query,
          headers: {
            Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'en-AU,en;q=0.9',
          },
        });
    
        const html = resp.data as string;
        const cache = extractApolloData(html);
        if (!cache) {
          return { platform: 'seek', jobs: [], page, error: 'Seek: could not extract Apollo cache from page' };
        }
    
        // Find the jobSearchV6(...) key in ROOT_QUERY
        const rootQuery = cache['ROOT_QUERY'] as Record<string, unknown> | undefined;
        if (!rootQuery) {
          return { platform: 'seek', jobs: [], page, error: 'Seek: ROOT_QUERY not found in Apollo cache' };
        }
    
        const jobSearchKey = Object.keys(rootQuery).find((k) => k.startsWith('jobSearchV6('));
        if (!jobSearchKey) {
          return { platform: 'seek', jobs: [], page, error: 'Seek: no jobSearchV6 result in Apollo cache' };
        }
    
        const result = rootQuery[jobSearchKey] as { data?: Array<{ __ref?: string }>; totalCount?: number };
        const refs = result?.data ?? [];
    
        const perPage = Math.min(params.per_page ?? 10, 25);
        const jobs: Job[] = [];
    
        for (const ref of refs) {
          if (!ref.__ref) continue;
          const jobData = resolveRef(cache, ref.__ref);
          if (!jobData) continue;
    
          const appCount = null; // Not exposed in Apollo cache (requires separate job detail call)
    
          if (params.max_applications != null && appCount != null && (appCount as number) > params.max_applications) {
            continue;
          }
    
          const id = String(jobData.id ?? '');
          const location = jobData.locations?.[0]?.label ?? '';
          const postedAt = jobData.listingDate?.dateTimeUtc ?? '';
    
          jobs.push({
            id: makeJobId('seek', id),
            title: String(jobData.title ?? ''),
            company: String(jobData.companyName ?? ''),
            location,
            employment_type: undefined,
            salary: String(jobData.salaryLabel ?? '') || undefined,
            description: jobData.teaser ? truncate(String(jobData.teaser)) : undefined,
            url: `https://au.seek.com/job/${id}`,
            posted_at: postedAt || undefined,
            applications_count: appCount,
            platform: 'seek',
          });
    
          if (jobs.length >= perPage) break;
        }
    
        // totalCount lives in the GraphQL result but the Apollo cache stores it
        // on the SearchV6Result under `totalCount` if present (it's in the page state JSON)
        const totalCountMatch = html.match(/"totalCount":(\d+)/);
        const total = totalCountMatch ? parseInt(totalCountMatch[1], 10) : undefined;
    
        return { platform: 'seek', jobs, total, page };
      } catch (err: unknown) {
        const msg = err instanceof Error ? err.message : String(err);
        return { platform: 'seek', jobs: [], page, error: `Seek: ${msg}` };
      }
    }
  • LinkedIn platform scraper implementation - called by the handler to scrape jobs from LinkedIn's guest job search API using cheerio HTML parsing.
    export async function linkedinSearch(params: JobSearchParams): Promise<PlatformResult> {
      const client = createHttpClient({
        Referer: 'https://www.linkedin.com/',
        'X-Li-Lang': 'en_AU',
      });
    
      const page = params.page ?? 1;
      const perPage = Math.min(params.per_page ?? 10, 25);
      const start = (page - 1) * perPage;
      const radiusMiles = kmToMiles(params.radius ?? 50);
    
      const query: Record<string, string | number> = {
        keywords: params.keyword,
        location: `${params.location}, Australia`,
        distance: radiusMiles,
        start,
        count: perPage,
      };
    
      if (params.employment_type?.length) {
        query.f_JT = params.employment_type.map((t) => JOB_TYPE_MAP[t]).join(',');
      }
      if (params.date_posted) {
        query.f_TPR = TIME_FILTER_MAP[params.date_posted];
      }
    
      try {
        const resp = await client.get(
          'https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search',
          { params: query, headers: { Accept: 'text/html,*/*' } }
        );
    
        const $ = cheerio.load(resp.data as string);
        const jobs: Job[] = [];
    
        $('li').each((_, el) => {
          const card = $(el);
          const linkEl = card.find('a.base-card__full-link');
          const url = linkEl.attr('href')?.split('?')[0] ?? '';
          const title =
            card.find('.base-search-card__title').text().trim() ||
            linkEl.attr('aria-label')?.trim() ||
            '';
          const company = card.find('.base-search-card__subtitle').text().trim();
          const location = card.find('.job-search-card__location').text().trim();
          const postedAt =
            card.find('time').attr('datetime') ||
            card.find('.job-search-card__listdate').text().trim();
          const applicantText = card
            .find('.num-applicants__caption, .applicant-count')
            .text()
            .trim();
          const appCount = applicantText ? parseApplicationCount(applicantText) : null;
    
          if (!title || !url) return;
          if (params.max_applications != null && appCount != null && appCount > params.max_applications) {
            return;
          }
    
          const rawId = url.split('/').filter(Boolean).pop() ?? url;
          jobs.push({
            id: makeJobId('linkedin', rawId),
            title,
            company,
            location,
            employment_type: undefined,
            salary: undefined,
            description: undefined,
            url,
            posted_at: postedAt || undefined,
            applications_count: appCount,
            platform: 'linkedin',
          });
        });
    
        return { platform: 'linkedin', jobs, page };
      } catch (err: unknown) {
        const msg = err instanceof Error ? err.message : String(err);
        return { platform: 'linkedin', jobs: [], page, error: msg };
      }
    }
Behavior2/5

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

No annotations are provided, so the description must disclose behavioral traits. It mentions the platforms and filters but fails to state that the operation is read-only, whether authentication is needed, or how pagination works. Important limitations (e.g., filter compatibility) are only hinted at for one filter.

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

Conciseness5/5

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

The description is two sentences: the first defines purpose and scope, the second lists filters. It is front-loaded with key information and contains no superfluous text.

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

Completeness3/5

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

Given the high parameter count (12) and no output schema, the description covers the core function adequately but lacks details on return format, pagination behavior, or error conditions. It is minimally complete for an agent to understand basic usage.

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?

All 12 parameters have schema descriptions (100% coverage), so the description adds minimal value beyond what the schema provides. The baseline score of 3 is appropriate; the description lists filter categories but does not elaborate on individual parameters.

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's purpose: searching for jobs on Australian platforms (Seek and LinkedIn). It lists supported filters, making it specific and informative. With no sibling tools, differentiation is unnecessary.

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

Usage Guidelines4/5

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

The description implies when to use the tool (for job searches in Australia) but does not explicitly state when not to use it or provide alternatives. Since there are no siblings, the implicit guidance is sufficient.

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

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/afrozk/mcp-job-toolkit'

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