CareJobs.psm1

# CareJobs: PowerShell client for the CareJobs.Now healthcare jobs API.
#
# The API is a remote MCP server at https://carejobs.now/api/mcp that needs no key.
# Every function sends one JSON-RPC request to it with Invoke-WebRequest and returns
# the tool's data as objects. Data from CareJobs.Now (https://carejobs.now), CC BY 4.0.

Set-StrictMode -Version 3.0

$script:ModuleVersion = '1.0.0'
$script:DefaultBaseUrl = 'https://carejobs.now'
$script:BaseUrl = $script:DefaultBaseUrl
$script:TimeoutSec = 60
$script:UserAgent = "carejobs-powershell/$script:ModuleVersion"
$script:NextId = 0

$script:Kinds = @(
    'nurses', 'nurse-practitioners', 'physicians', 'nursing-assistants', 'caregivers',
    'therapy-rehab', 'mental-health', 'pharmacy', 'imaging', 'lab', 'surgical-techs',
    'emts-paramedics', 'medical-assistants', 'dental', 'admin', 'allied-health', 'ai-training'
)

# Windows PowerShell 5.1 on older .NET Framework may not offer TLS 1.2 by default.
if ($PSVersionTable.PSEdition -ne 'Core') {
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}

# -- private helpers ------------------------------------------------------------------

function New-CareJobsErrorRecord {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object in memory and changes no state.')]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [string] $Message,
        [string] $Reason,
        [string] $Tool,
        [object] $Status,
        [object] $RpcCode,
        [Exception] $Inner
    )
    $exception = New-Object System.Exception -ArgumentList $Message, $Inner
    $exception.Data['Reason'] = $Reason
    $exception.Data['Tool'] = $Tool
    $exception.Data['Status'] = $Status
    $exception.Data['RpcCode'] = $RpcCode
    $category = switch ($Reason) {
        'tool' { [System.Management.Automation.ErrorCategory]::InvalidArgument }
        'rpc' { [System.Management.Automation.ErrorCategory]::InvalidOperation }
        'http' { [System.Management.Automation.ErrorCategory]::InvalidResult }
        'network' { [System.Management.Automation.ErrorCategory]::ConnectionError }
        'timeout' { [System.Management.Automation.ErrorCategory]::OperationTimeout }
        default { [System.Management.Automation.ErrorCategory]::ProtocolError }
    }
    New-Object System.Management.Automation.ErrorRecord -ArgumentList $exception, "CareJobs.$Reason", $category, $Tool
}

function ConvertFrom-CareJobsJson {
    param([string] $Json, [string] $Tool)
    try {
        $value = $Json | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw (New-CareJobsErrorRecord -Message 'The server did not answer with JSON' -Reason 'protocol' -Tool $Tool -Inner $_.Exception)
    }
    if ($null -eq $value -or $value -isnot [System.Management.Automation.PSCustomObject]) {
        throw (New-CareJobsErrorRecord -Message 'The server did not answer with a JSON-RPC object' -Reason 'protocol' -Tool $Tool)
    }
    $value
}

function Test-CareJobsProperty {
    param($Object, [string] $Name)
    $null -ne $Object -and $Object -is [System.Management.Automation.PSCustomObject] -and $null -ne $Object.PSObject.Properties[$Name]
}

# Reads the JSON-RPC message out of a plain JSON body or a text/event-stream body.
function Read-CareJobsMessage {
    param([string] $Body, [string] $ContentType, [int] $Id, [string] $Tool)
    if ($ContentType -notmatch 'text/event-stream') {
        return (ConvertFrom-CareJobsJson -Json $Body -Tool $Tool)
    }
    $fallback = $null
    foreach ($eventText in ($Body -replace "`r`n", "`n") -split "`n`n") {
        $data = @()
        foreach ($line in $eventText -split "`n") {
            if ($line.StartsWith('data:')) {
                $data += $line.Substring(5).TrimStart(' ')
            }
        }
        if ($data.Count -eq 0) { continue }
        try {
            $message = ConvertFrom-CareJobsJson -Json ($data -join "`n") -Tool $Tool
        }
        catch {
            continue
        }
        if ((Test-CareJobsProperty $message 'id') -and $message.id -is [ValueType] -and $message.id -eq $Id) {
            return $message
        }
        if ($null -eq $fallback -and ((Test-CareJobsProperty $message 'result') -or (Test-CareJobsProperty $message 'error'))) {
            $fallback = $message
        }
    }
    if ($null -ne $fallback) { return $fallback }
    throw (New-CareJobsErrorRecord -Message 'The event stream held no JSON-RPC answer' -Reason 'protocol' -Tool $Tool)
}

function Get-CareJobsHttpErrorDetail {
    param([int] $Status, [string] $Body, [string] $ContentType, [int] $Id)
    $detail = "HTTP $Status"
    try {
        $message = Read-CareJobsMessage -Body $Body -ContentType $ContentType -Id $Id -Tool $null
        if ((Test-CareJobsProperty $message 'error') -and (Test-CareJobsProperty $message.error 'message') -and $message.error.message) {
            return "${detail}: $($message.error.message)"
        }
    }
    catch {
        Write-Debug "The error body is not JSON-RPC, so its raw text is used: $_"
    }
    $text = "$Body".Trim()
    if ($text.Length -eq 0) { return $detail }
    if ($text.Length -gt 200) { $text = $text.Substring(0, 200) }
    "${detail}: $text"
}

function Get-CareJobsHeaderValue {
    param($Headers, [string] $Name)
    if ($null -eq $Headers) { return '' }
    foreach ($key in @($Headers.Keys)) {
        if ($key -eq $Name) { return [string](@($Headers[$key])[0]) }
    }
    ''
}

# Sends one JSON-RPC request and returns the result of its answer.
function Invoke-CareJobsRpc {
    param(
        [Parameter(Mandatory)] [string] $Method,
        [Parameter(Mandatory)] [System.Collections.IDictionary] $Params,
        [string] $Tool
    )
    $script:NextId++
    $id = $script:NextId
    $payload = [ordered]@{ jsonrpc = '2.0'; id = $id; method = $Method; params = $Params }
    $json = $payload | ConvertTo-Json -Depth 20 -Compress
    $uri = $script:BaseUrl.TrimEnd('/') + '/api/mcp'

    $request = @{
        Uri             = $uri
        Method          = 'Post'
        Body            = [System.Text.Encoding]::UTF8.GetBytes($json)
        ContentType     = 'application/json'
        Headers         = @{ Accept = 'application/json, text/event-stream' }
        UserAgent       = $script:UserAgent
        TimeoutSec      = $script:TimeoutSec
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }
    if ($PSVersionTable.PSVersion.Major -ge 7) {
        $request['SkipHttpErrorCheck'] = $true
    }

    $status = 0
    $contentType = ''
    $body = ''
    try {
        $response = Invoke-WebRequest @request
        $status = [int]$response.StatusCode
        $contentType = Get-CareJobsHeaderValue $response.Headers 'Content-Type'
        $body = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())
    }
    catch [System.Net.WebException] {
        # Windows PowerShell 5.1: a non-2xx status, or no answer at all.
        $webError = $_.Exception
        if ($null -ne $webError.Response) {
            $status = [int]$webError.Response.StatusCode
            $contentType = [string]$webError.Response.ContentType
            $reader = New-Object System.IO.StreamReader -ArgumentList $webError.Response.GetResponseStream(), ([System.Text.Encoding]::UTF8)
            try { $body = $reader.ReadToEnd() } finally { $reader.Dispose() }
        }
        elseif ($webError.Status -eq [System.Net.WebExceptionStatus]::Timeout) {
            throw (New-CareJobsErrorRecord -Message "No answer from $uri within $($script:TimeoutSec) s" -Reason 'timeout' -Tool $Tool -Inner $webError)
        }
        else {
            throw (New-CareJobsErrorRecord -Message "Could not reach ${uri}: $($webError.Message)" -Reason 'network' -Tool $Tool -Inner $webError)
        }
    }
    catch {
        $failure = $_.Exception
        $isTimeout = $false
        for ($e = $failure; $null -ne $e; $e = $e.InnerException) {
            if ($e -is [System.TimeoutException] -or $e -is [System.Threading.Tasks.TaskCanceledException] -or $e.Message -match 'Timeout|timed out') {
                $isTimeout = $true
            }
        }
        if ($isTimeout) {
            throw (New-CareJobsErrorRecord -Message "No answer from $uri within $($script:TimeoutSec) s" -Reason 'timeout' -Tool $Tool -Inner $failure)
        }
        throw (New-CareJobsErrorRecord -Message "Could not reach ${uri}: $($failure.Message)" -Reason 'network' -Tool $Tool -Inner $failure)
    }

    if ($status -lt 200 -or $status -gt 299) {
        $detail = Get-CareJobsHttpErrorDetail -Status $status -Body $body -ContentType $contentType -Id $id
        throw (New-CareJobsErrorRecord -Message $detail -Reason 'http' -Tool $Tool -Status $status)
    }

    $message = Read-CareJobsMessage -Body $body -ContentType $contentType -Id $id -Tool $Tool
    if ((Test-CareJobsProperty $message 'error') -and $null -ne $message.error) {
        $rpcError = $message.error
        if ($rpcError -isnot [System.Management.Automation.PSCustomObject]) {
            throw (New-CareJobsErrorRecord -Message 'Malformed JSON-RPC error' -Reason 'protocol' -Tool $Tool)
        }
        $text = 'JSON-RPC error'
        if ((Test-CareJobsProperty $rpcError 'message') -and $rpcError.message) { $text = [string]$rpcError.message }
        $code = $null
        if (Test-CareJobsProperty $rpcError 'code') { $code = $rpcError.code }
        throw (New-CareJobsErrorRecord -Message $text -Reason 'rpc' -Tool $Tool -RpcCode $code)
    }
    if (-not (Test-CareJobsProperty $message 'result') -or $message.result -isnot [System.Management.Automation.PSCustomObject]) {
        throw (New-CareJobsErrorRecord -Message 'The answer held no result' -Reason 'protocol' -Tool $Tool)
    }
    $message.result
}

# Calls one tool and returns @{ Data = ...; Text = ... }.
function Invoke-CareJobsToolCall {
    param(
        [Parameter(Mandatory)] [string] $Name,
        [System.Collections.IDictionary] $Arguments
    )
    $clean = [ordered]@{}
    if ($null -ne $Arguments) {
        foreach ($key in @($Arguments.Keys)) {
            if ($null -ne $Arguments[$key]) { $clean[[string]$key] = $Arguments[$key] }
        }
    }
    $raw = Invoke-CareJobsRpc -Method 'tools/call' -Params ([ordered]@{ name = $Name; arguments = $clean }) -Tool $Name

    $texts = @()
    if ((Test-CareJobsProperty $raw 'content') -and $null -ne $raw.content) {
        foreach ($part in @($raw.content)) {
            if ((Test-CareJobsProperty $part 'type') -and $part.type -eq 'text' -and (Test-CareJobsProperty $part 'text') -and $part.text -is [string]) {
                $texts += $part.text
            }
        }
    }
    $text = $texts -join "`n"

    if ((Test-CareJobsProperty $raw 'isError') -and $raw.isError -eq $true) {
        if (-not $text) { $text = "$Name failed" }
        throw (New-CareJobsErrorRecord -Message $text -Reason 'tool' -Tool $Name)
    }
    if (-not (Test-CareJobsProperty $raw 'structuredContent') -or $raw.structuredContent -isnot [System.Management.Automation.PSCustomObject]) {
        throw (New-CareJobsErrorRecord -Message "$Name answered without structured content" -Reason 'protocol' -Tool $Name)
    }
    [pscustomobject]@{ Data = $raw.structuredContent; Text = $text }
}

function Write-CareJobsAnswer {
    param($Answer, [switch] $Summary)
    if ($Summary) { $Answer.Text } else { $Answer.Data }
}

# -- public functions -----------------------------------------------------------------

function Search-CareJobs {
    <#
    .SYNOPSIS
    Searches the open US healthcare jobs on CareJobs.Now.

    .DESCRIPTION
    Every filter is optional. By default each job is written to the pipeline, with
    title, employer, kind, pay, schedule, location, listed date and url (the page on
    CareJobs.Now where the job is applied for). -Raw returns the whole answer
    instead (total, pay, jobs and more), and -Summary the server's readable text.

    .PARAMETER Query
    Words every job must carry in its title, place, department or schedule ("ICU", "night", "Houston").

    .PARAMETER Kind
    A kind of job, for example nurses or nursing-assistants. Get-CareJobsKind lists them.

    .PARAMETER Employer
    An employer slug ("kaiser-permanente") or name.

    .PARAMETER State
    A US state: TX or Texas.

    .PARAMETER Remote
    Keeps only remote jobs.

    .PARAMETER MinHourlyUsd
    Only jobs whose posted hourly ceiling reaches this (1 to 1000).

    .PARAMETER Sort
    mixed (the default), newest or highest_pay.

    .PARAMETER Limit
    How many jobs to return, 1 to 25 (default 10).

    .PARAMETER Raw
    Returns the whole answer object (total, pay, jobs, more) instead of the jobs.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    Search-CareJobs -Query ICU -Kind nurses -State TX -Limit 5 | Format-Table title, employer, pay, location

    .EXAMPLE
    (Search-CareJobs -Kind caregivers -State OH -Raw).total

    .EXAMPLE
    Search-CareJobs night -Kind nurses -Sort highest_pay -Summary

    .LINK
    https://carejobs.now/data
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'CareJobs is the name of the service, not a plural.')]
    [CmdletBinding()]
    [OutputType([pscustomobject], [string])]
    param(
        [Parameter(Position = 0)] [string] $Query,
        [string] $Kind,
        [string] $Employer,
        [string] $State,
        [switch] $Remote,
        [ValidateRange(1, 1000)] [int] $MinHourlyUsd,
        [ValidateSet('mixed', 'newest', 'highest_pay')] [string] $Sort,
        [ValidateRange(1, 25)] [int] $Limit,
        [switch] $Raw,
        [switch] $Summary
    )
    $arguments = [ordered]@{}
    if ($Query) { $arguments.query = $Query }
    if ($Kind) { $arguments.kind = $Kind }
    if ($Employer) { $arguments.employer = $Employer }
    if ($State) { $arguments.state = $State }
    if ($Remote) { $arguments.remote = $true }
    if ($PSBoundParameters.ContainsKey('MinHourlyUsd')) { $arguments.min_hourly_usd = $MinHourlyUsd }
    if ($Sort) { $arguments.sort = $Sort }
    if ($PSBoundParameters.ContainsKey('Limit')) { $arguments.limit = $Limit }
    try {
        $answer = Invoke-CareJobsToolCall -Name 'search_healthcare_jobs' -Arguments $arguments
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    if ($Summary) { return $answer.Text }
    if ($Raw) { return $answer.Data }
    if (Test-CareJobsProperty $answer.Data 'jobs') {
        foreach ($job in @($answer.Data.jobs)) {
            if ($null -ne $job) {
                $job.PSObject.TypeNames.Insert(0, 'CareJobs.Job')
                $job
            }
        }
    }
}

function Get-CareJobsPay {
    <#
    .SYNOPSIS
    Gets the hourly pay US healthcare jobs post, measured from open jobs on CareJobs.Now.

    .DESCRIPTION
    Pay is what each posting states, before shift differentials and bonuses. With no
    parameters: every kind of job. With -Kind: that job's pay and the employers posting
    the most for it. With -Employer: its pay overall and by kind. With both: that
    employer's pay for that kind.

    .PARAMETER Kind
    A kind of job, for example nurses. Get-CareJobsKind lists them.

    .PARAMETER Employer
    An employer slug ("kaiser-permanente") or name.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    (Get-CareJobsPay -Kind nurses).pay.median

    .EXAMPLE
    Get-CareJobsPay -Employer kaiser-permanente -Summary

    .LINK
    https://carejobs.now/data
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject], [string])]
    param(
        [Parameter(Position = 0)] [string] $Kind,
        [string] $Employer,
        [switch] $Summary
    )
    $arguments = [ordered]@{}
    if ($Kind) { $arguments.kind = $Kind }
    if ($Employer) { $arguments.employer = $Employer }
    try {
        $answer = Invoke-CareJobsToolCall -Name 'get_healthcare_pay' -Arguments $arguments
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    Write-CareJobsAnswer $answer -Summary:$Summary
}

function Get-CareJobsMarket {
    <#
    .SYNOPSIS
    Gets how many US healthcare jobs are open on CareJobs.Now, and where.

    .DESCRIPTION
    How many jobs are open, how many were first listed in the last 7 days, and the
    open jobs by kind of job, by state and by employer.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    Get-CareJobsMarket -Summary

    .LINK
    https://carejobs.now/data
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject], [string])]
    param([switch] $Summary)
    try {
        $answer = Invoke-CareJobsToolCall -Name 'get_healthcare_job_market' -Arguments @{}
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    Write-CareJobsAnswer $answer -Summary:$Summary
}

function Get-CareJobsByState {
    <#
    .SYNOPSIS
    Gets the open US healthcare jobs by state.

    .DESCRIPTION
    With no state: every state. With -State: its jobs by kind, pay, towns, employers
    and newest jobs. With -State and -Kind: the same for that kind (a kind needs a state).

    .PARAMETER State
    A US state: OH or Ohio.

    .PARAMETER Kind
    A kind of job, for example nursing-assistants. Needs -State.

    .PARAMETER Limit
    How many of the newest jobs to return, 1 to 25.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    (Get-CareJobsByState OH -Kind nursing-assistants).open_jobs

    .EXAMPLE
    Get-CareJobsByState -Summary

    .LINK
    https://carejobs.now/data
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject], [string])]
    param(
        [Parameter(Position = 0)] [string] $State,
        [string] $Kind,
        [ValidateRange(1, 25)] [int] $Limit,
        [switch] $Summary
    )
    $arguments = [ordered]@{}
    if ($State) { $arguments.state = $State }
    if ($Kind) { $arguments.kind = $Kind }
    if ($PSBoundParameters.ContainsKey('Limit')) { $arguments.limit = $Limit }
    try {
        $answer = Invoke-CareJobsToolCall -Name 'get_healthcare_jobs_by_state' -Arguments $arguments
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    Write-CareJobsAnswer $answer -Summary:$Summary
}

function Get-CareJobsEmployer {
    <#
    .SYNOPSIS
    Lists the healthcare employers CareJobs.Now reads, or gets one of them.

    .DESCRIPTION
    Without -Slug: the employers (and AI-training programs and job boards) the site
    reads, most open jobs first, each written to the pipeline (-Raw returns the whole
    answer). With -Slug: one employer, AI-training program or job board, by slug or
    name: what it is, where it hires, its open jobs by kind, the pay it posts, its
    newest and best-paid jobs, and links to its pages. A slug can come from the
    pipeline, so a list can be piped into this function.

    .PARAMETER Slug
    One employer: its slug ("kaiser-permanente") or name.

    .PARAMETER Query
    Part of the name ("Kaiser", "children").

    .PARAMETER Type
    employer, ai-program or job-board.

    .PARAMETER Care
    A kind of care, for example home-care or health-system.

    .PARAMETER State
    Keeps employers with open jobs in this state.

    .PARAMETER Kind
    Keeps employers with open jobs of this kind.

    .PARAMETER Limit
    How many employers to return, 1 to 100 (default 20).

    .PARAMETER Raw
    Returns the whole list answer instead of the entries.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    Get-CareJobsEmployer -Query Kaiser -Limit 3 | Format-Table name, open_jobs

    .EXAMPLE
    Get-CareJobsEmployer -Slug kaiser-permanente -Summary

    .LINK
    https://carejobs.now/data
    #>

    [CmdletBinding(DefaultParameterSetName = 'List')]
    [OutputType([pscustomobject], [string])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'One', ValueFromPipelineByPropertyName)] [string] $Slug,
        [Parameter(Position = 0, ParameterSetName = 'List')] [string] $Query,
        [Parameter(ParameterSetName = 'List')] [ValidateSet('employer', 'ai-program', 'job-board')] [string] $Type,
        [Parameter(ParameterSetName = 'List')] [string] $Care,
        [Parameter(ParameterSetName = 'List')] [string] $State,
        [Parameter(ParameterSetName = 'List')] [string] $Kind,
        [Parameter(ParameterSetName = 'List')] [ValidateRange(1, 100)] [int] $Limit,
        [Parameter(ParameterSetName = 'List')] [switch] $Raw,
        [switch] $Summary
    )
    process {
        try {
            if ($PSCmdlet.ParameterSetName -eq 'One') {
                $answer = Invoke-CareJobsToolCall -Name 'get_healthcare_employer' -Arguments ([ordered]@{ employer = $Slug })
            }
            else {
                $arguments = [ordered]@{}
                if ($Query) { $arguments.query = $Query }
                if ($Type) { $arguments.type = $Type }
                if ($Care) { $arguments.care = $Care }
                if ($State) { $arguments.state = $State }
                if ($Kind) { $arguments.kind = $Kind }
                if ($PSBoundParameters.ContainsKey('Limit')) { $arguments.limit = $Limit }
                $answer = Invoke-CareJobsToolCall -Name 'list_healthcare_employers' -Arguments $arguments
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
        if ($Summary) { return $answer.Text }
        if ($PSCmdlet.ParameterSetName -eq 'One' -or $Raw) { return $answer.Data }
        if (Test-CareJobsProperty $answer.Data 'entries') {
            foreach ($entry in @($answer.Data.entries)) {
                if ($null -ne $entry) {
                    $entry.PSObject.TypeNames.Insert(0, 'CareJobs.Employer')
                    $entry
                }
            }
        }
    }
}

function Get-CareJobsKind {
    <#
    .SYNOPSIS
    Lists the kinds of healthcare job the CareJobs.Now API knows.

    .DESCRIPTION
    Returns the 17 kind slugs (nurses, nurse-practitioners, physicians, ...) that the
    -Kind parameters take. Works offline.

    .EXAMPLE
    Get-CareJobsKind
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    foreach ($kind in $script:Kinds) { $kind }
}

function Get-CareJobsTool {
    <#
    .SYNOPSIS
    Lists the tools the CareJobs.Now MCP server offers, with their input schemas.

    .EXAMPLE
    Get-CareJobsTool | Select-Object name, description

    .LINK
    https://carejobs.now/api/mcp
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()
    try {
        $result = Invoke-CareJobsRpc -Method 'tools/list' -Params ([ordered]@{})
        if (-not (Test-CareJobsProperty $result 'tools') -or $null -eq $result.tools) {
            throw (New-CareJobsErrorRecord -Message 'tools/list answered without a tools list' -Reason 'protocol')
        }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    foreach ($tool in @($result.tools)) { $tool }
}

function Invoke-CareJobsTool {
    <#
    .SYNOPSIS
    Calls any tool of the CareJobs.Now MCP server by name.

    .DESCRIPTION
    For a tool added after this version of the module. Arguments keep the API's names;
    $null values are left out. Returns the tool's data, or with -Summary its readable text.

    .PARAMETER Name
    The tool's name, for example search_healthcare_jobs. Get-CareJobsTool lists them.

    .PARAMETER Arguments
    The tool's arguments as a hashtable.

    .PARAMETER Summary
    Returns the server's readable summary as text.

    .EXAMPLE
    Invoke-CareJobsTool -Name search_healthcare_jobs -Arguments @{ kind = 'lab'; state = 'NY'; limit = 3 }

    .LINK
    https://carejobs.now/api/mcp
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject], [string])]
    param(
        [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Name,
        [Parameter(Position = 1)] [System.Collections.IDictionary] $Arguments = @{},
        [switch] $Summary
    )
    try {
        $answer = Invoke-CareJobsToolCall -Name $Name -Arguments $Arguments
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    Write-CareJobsAnswer $answer -Summary:$Summary
}

function Set-CareJobsOption {
    <#
    .SYNOPSIS
    Changes the host, timeout or User-Agent the CareJobs functions use in this session.

    .DESCRIPTION
    The defaults are https://carejobs.now, 60 seconds and carejobs-powershell/<version>.
    The server answers a query it has not seen before in up to about 25 seconds, then
    repeats of it in a fraction of one, which is why the default timeout is a minute.

    .PARAMETER BaseUrl
    The host; the MCP endpoint is <BaseUrl>/api/mcp.

    .PARAMETER TimeoutSec
    Seconds one call may take.

    .PARAMETER UserAgent
    Sent as the User-Agent header.

    .EXAMPLE
    Set-CareJobsOption -TimeoutSec 120 -UserAgent 'my-script/1.0'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidatePattern('^https?://[^/\s]+')] [string] $BaseUrl,
        [ValidateRange(1, 3600)] [int] $TimeoutSec,
        [ValidateNotNullOrEmpty()] [string] $UserAgent
    )
    if ($PSCmdlet.ShouldProcess('CareJobs module options', 'Set')) {
        if ($BaseUrl) { $script:BaseUrl = $BaseUrl.TrimEnd('/') }
        if ($PSBoundParameters.ContainsKey('TimeoutSec')) { $script:TimeoutSec = $TimeoutSec }
        if ($UserAgent) { $script:UserAgent = $UserAgent }
    }
}

# Tab completion of the kind slugs; any other string is still accepted, so a kind
# the server adds later works without a new version of this module.
Register-ArgumentCompleter -CommandName Search-CareJobs, Get-CareJobsPay, Get-CareJobsByState, Get-CareJobsEmployer -ParameterName Kind -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete)
    $null = $commandName, $parameterName
    $script:Kinds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        New-Object System.Management.Automation.CompletionResult -ArgumentList $_, $_, 'ParameterValue', $_
    }
}

Export-ModuleMember -Function Search-CareJobs, Get-CareJobsPay, Get-CareJobsMarket, Get-CareJobsByState, Get-CareJobsEmployer, Get-CareJobsKind, Get-CareJobsTool, Invoke-CareJobsTool, Set-CareJobsOption