import { type InferSchema, type ToolMetadata } from "xmcp"; import { headers } from "xmcp/headers"; import { z } from "zod"; import { getScimBaseUrl } from "../utils/getSCIMBaseUrl"; import { getScimToken } from "../utils/getSCIMToken"; import { maskPII, PII_FIELDS } from "../utils/piiMasking"; import { readJsonBody } from "../utils/responseBody"; export const metadata: ToolMetadata = { name: "get-users", description: "List/search SCIM user resources with optional filtering and pagination", annotations: { title: "Get Users", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }; export const schema = { filter: z .string() .optional() .describe( "SCIM filter expression per RFC 7644 Section 3.4.2.2. Examples: 'userName eq \"bjensen\"', 'emails.value co \"example.com\"'" ), startIndex: z .number() .optional() .describe( "The 1-based index of the first query result. A value less than 1 SHALL be interpreted as 1." ), count: z .number() .optional() .describe( "Non-negative integer. Specifies the desired maximum number of query results per page." ), sortBy: z .string() .optional() .describe( "Attribute path to sort results by (e.g., 'userName', 'meta.created')" ), sortOrder: z .enum(["ascending", "descending"]) .optional() .describe("Sort order for results. Default: ascending"), attributes: z .string() .optional() .describe( "Comma-separated list of attribute names to return (e.g., 'userName,emails')" ), excludedAttributes: z .string() .optional() .describe("Comma-separated list of attribute names to exclude"), piiMasking: z .boolean() .optional() .default(true) .describe( "Enable PII masking for sensitive fields (username, emails, phone numbers, addresses). When true, values are partially masked while maintaining readability. Default: true" ), }; export default async function getUsers(params: InferSchema) { const { filter, startIndex, count, sortBy, sortOrder, attributes, excludedAttributes, piiMasking = true, } = params; const requestHeaders = headers(); const apiToken = getScimToken(requestHeaders); const baseUrl = getScimBaseUrl(requestHeaders); if (!apiToken) { throw new Error( "Missing required headers: x-scim-api-token or SCIM_API_TOKEN env" ); } if (!baseUrl) { throw new Error( "Missing required headers: x-scim-base-url or SCIM_API_BASE_URL env" ); } const url = new URL(`${baseUrl}/Users`); if (filter) { url.searchParams.append("filter", filter); } if (startIndex !== undefined) { url.searchParams.append("startIndex", startIndex.toString()); } if (count !== undefined) { url.searchParams.append("count", count.toString()); } if (sortBy) { url.searchParams.append("sortBy", sortBy); } if (sortOrder) { url.searchParams.append("sortOrder", sortOrder); } if (attributes) { url.searchParams.append("attributes", attributes); } if (excludedAttributes) { url.searchParams.append("excludedAttributes", excludedAttributes); } const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/scim+json", Authorization: `Bearer ${apiToken}`, }, }); if (!response.ok) { const errorBody = await readJsonBody(response); throw new Error( `Failed to get users: ${response.status} ${response.statusText}. ${JSON.stringify(errorBody)}` ); } let data = await response.json(); // Apply PII masking if enabled if (piiMasking) { data = maskPII(data, PII_FIELDS); } return { content: [ { type: "text", text: `Get users successfully`, }, ], structuredContent: data ?? undefined, }; }