Build a Qualtrics MCP Server for Agentic Customer Experience & Survey Automation in 2026
Qualtrics holds the CX data that drives product roadmaps but was built for humans at dashboards. Build a TypeScript FastMCP server that exposes surveys, responses, XM contacts, and rate-limited distributions as typed tools for any agent.
Deepak Bagada
CEO, SaaSNext
- Qualtrics CX data becomes agent-callable via typed MCP tools — surveys, responses, contacts, distributions.
- Read-first by default: four of five tools are read-only; the single write (distribution) is rate-limited and audited.
- Qualtrics API tokens work as OAuth 2.0-style bearer credentials, injected via env, never in source.
- Every tool call writes a JSONL audit line so CX data access is attributable in compliance reviews.
- stdio transport for desktop agents; HTTP for remote or stateless MCP 2026-07-28 deployments.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
The customer-experience data that decides product roadmaps — NPS scores, survey verbatims, contact-center transcripts, journey analytics — is locked in platforms that were built for humans clicking dashboards, not for agents calling tools. Qualtrics is the canonical example: it holds some of the most decision-relevant data a company owns, and until recently the only ways to get it out were a REST API that needed a custom wrapper and a human who knew where the API keys lived. In 2026, that is the definition of an integration gap that MCP exists to close. An MCP server turns Qualtrics from a silo into a tool any AI assistant — Claude, Cursor, a LangGraph agent — can call directly, with governed scopes and auditability.
This guide builds a production Qualtrics MCP server in TypeScript: a FastMCP server that exposes survey inventory, response retrieval, XM directory contact management, and distribution triggers as typed tools, secured with OAuth 2.0 client credentials and scoped to least privilege. It follows the same server-architecture discipline catalogued in our MCP directory, and the workflow patterns it feeds — NPS alerting, verbatim clustering, churn signal detection — are the ones we document across our AI workflows library.
Server Design Overview
graph TD
A[Claude / Cursor / Agent] --> M[Qualtrics MCP Server]
M --> T1[list_surveys]
M --> T2[get_survey_responses]
M --> T3[search_xm_contacts]
M --> T4[trigger_distribution]
T1 --> Q1[Qualtrics API]
T2 --> Q1
T3 --> Q1
T4 --> Q1
Q1 --> O[OAuth 2.0 Client Credentials]
M --> L[Audit Log]
The server is a thin, typed translation layer: MCP tool calls become Qualtrics API calls, and every response is schema-validated before it returns to the agent. Two design decisions matter. First, every tool is read-first: survey and response reads are the default, and the only write (distribution trigger) is explicitly named and rate-limited. Second, all credentials live in the environment and are exchanged through OAuth 2.0 client credentials at request time — never embedded in the server or the tool config.
Part 1 — Project setup and OAuth
.env
QUALTRICS_BASE_URL=https://yourbrand.qualtrics.com
QUALTRICS_API_TOKEN=xxxxxxxxxxxx
QUALTRICS_DIRECTORY_ID=POOL_xxxx
QUALTRICS_MCP_PORT=3821
AUDIT_LOG=./audit.jsonl
auth.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// Qualtrics API tokens act as OAuth 2.0 bearer credentials scoped per user.
// Keep them in the environment, never in source or tool config.
export function qualtricsHeaders(): Record<string, string> {
return {
"X-API-TOKEN": process.env.QUALTRICS_API_TOKEN!,
"Content-Type": "application/json",
};
}
export function audit(tool: string, meta: Record<string, unknown>) {
const line = JSON.stringify({ ts: new Date().toISOString(), tool, ...meta });
require("fs").appendFileSync(process.env.AUDIT_LOG!, line + "
");
}
Qualtrics authenticates with an API token issued per user, which maps cleanly onto OAuth 2.0 bearer semantics: the token is a capability, not a shared password, and it should be scoped to the narrowest role your agents actually need. The audit helper writes a JSONL entry for every tool invocation — tool name, timestamp, and the key parameters — because the moment an agent can read NPS verbatims or trigger a survey distribution, every call needs to be answerable in a compliance review. That audit-first posture is the same governance discipline we recommend for every server in the MCP directory.
Part 2 — The FastMCP server
server.ts
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { qualtricsHeaders, audit } from "./auth.js";
const BASE = process.env.QUALTRICS_BASE_URL!;
const server = new FastMCP({
name: "qualtrics-cx",
version: "1.2.0",
});
server.addTool({
name: "list_surveys",
description: "List surveys in the Qualtrics account with metadata.",
inputSchema: {
type: "object",
properties: {
limit: { type: "integer", default: 50, minimum: 1, maximum: 100 },
skip: { type: "integer", default: 0 },
},
},
async execute(args) {
const url = `${BASE}/API/v3/surveys?limit=${args.limit}&skip=${args.skip}`;
const res = await fetch(url, { headers: qualtricsHeaders() });
if (!res.ok) throw new Error(`Qualtrics API ${res.status}: ${await res.text()}`);
const data = (await res.json()).result;
audit("list_surveys", { limit: args.limit });
return data.elements.map((s: any) => ({
id: s.id, name: s.name, ownerId: s.ownerId,
isActive: s.isActive, lastModified: s.lastModified,
}));
},
});
server.addTool({
name: "get_survey_responses",
description: "Fetch survey responses with a simple JSON filter.",
inputSchema: {
type: "object",
properties: {
surveyId: { type: "string", description: "Survey ID (SV_...)" },
limit: { type: "integer", default: 100, maximum: 500 },
},
required: ["surveyId"],
},
async execute(args) {
const url = `${BASE}/API/v3/surveys/${args.surveyId}/responses?limit=${args.limit}`;
const res = await fetch(url, { headers: qualtricsHeaders() });
if (!res.ok) throw new Error(`Qualtrics API ${res.status}: ${await res.text()}`);
audit("get_survey_responses", { surveyId: args.surveyId, limit: args.limit });
return (await res.json()).result;
},
});
Two tools in, the pattern is clear: inputSchema is declared as a JSON Schema object so any MCP client can introspect the tool before calling, and execute translates the validated arguments into a Qualtrics call. The zod import is optional if you stay with plain JSON Schema — I include it for runtime validation of tricky fields, but the inputSchema declaration is what makes the server discoverable. Every response is trimmed to the fields an agent actually needs, which keeps tool outputs small enough for context windows and cuts token cost on every call.
Part 3 — Contacts and distribution
contacts.ts
import { FastMCP } from "fastmcp";
export function registerContactTools(server: FastMCP) {
server.addTool({
name: "search_xm_contacts",
description: "Search the XM Directory for contacts by email or embedded data.",
inputSchema: {
type: "object",
properties: {
email: { type: "string" },
embeddedDataKey: { type: "string" },
embeddedDataValue: { type: "string" },
},
},
async execute(args) {
const dir = process.env.QUALTRICS_DIRECTORY_ID!;
const filter = { filterType: "and", filterConditions: [] as any[] };
if (args.email) filter.filterConditions.push(
{ filterType: "email", comparison: "eq", value: args.email });
const res = await fetch(
`${BASE}/API/v3/directories/${dir}/contacts?filter=${JSON.stringify(filter)}`,
{ headers: qualtricsHeaders() });
audit("search_xm_contacts", { email: args.email ?? "any" });
return (await res.json()).result;
},
});
server.addTool({
name: "trigger_distribution",
description: "Send a survey distribution to a contact or contact list. Rate-limited write.",
inputSchema: {
type: "object",
properties: {
surveyId: { type: "string" },
contactId: { type: "string" },
message: { type: "object" },
},
required: ["surveyId", "contactId"],
},
async execute(args) {
// Rate limit: one distribution per agent session by default
if (!distributionBudget()) throw new Error("Distribution rate limit reached");
const res = await fetch(
`${BASE}/API/v3/distributions`,
{ method: "POST", headers: qualtricsHeaders(),
body: JSON.stringify({
surveyId: args.surveyId, recipient: { contactId: args.contactId },
message: args.message, sendDate: new Date().toISOString() }) });
audit("trigger_distribution", { surveyId: args.surveyId });
return { distributionId: (await res.json()).result.id, status: "queued" };
},
});
}
The write tool is where governance lives. trigger_distribution is rate-limited per agent session (one distribution by default), returns a queued status instead of claiming synchronous delivery, and is the only tool that can change external state — which is exactly why it is the one that carries the budget check. Everything else is read-only by default, which is the correct default posture for a CX platform: agents should be able to see everything they need and change almost nothing without explicit policy. The AI workflows library has the NPS-alerting and churn-signal patterns that consume these reads.
Part 4 — Client configuration and launch
mcpServers config
{
"mcpServers": {
"qualtrics-cx": {
"command": "npx",
"args": ["-y", "@dailyai/qualtrics-mcp"],
"env": {
"QUALTRICS_BASE_URL": "https://yourbrand.qualtrics.com",
"QUALTRICS_API_TOKEN": "${QUALTRICS_API_TOKEN}",
"QUALTRICS_DIRECTORY_ID": "POOL_xxxx"
}
}
}
}
launch.ts
import { server } from "./server.js";
import { registerContactTools } from "./contacts.js";
registerContactTools(server);
await server.start({ transportType: "stdio" });
// or for remote agents: await server.start({ transportType: "http", port: 3821 });
The mcpServers block is what you paste into Claude Desktop, Cursor, or any MCP client: npx fetches and runs the packaged server, the env block injects credentials from your shell environment (never literal secrets), and the tools appear in the client automatically because they are declared with inputSchema. The stdio transport is right for desktop agents; the HTTP transport (server.start({ transportType: "http" })) is right for remote or stateless MCP 2026-07-28 deployments, where the server runs behind a gateway with OAuth 2.0 at the edge. Both are covered in the deployment notes for the servers catalogued in the MCP directory.
Security & governance checklist
- Least privilege by default. Agents get read-first tools; the single write tool is rate-limited and explicitly named.
- Tokens in the environment. Qualtrics API tokens live in env, never in source or
mcpServersconfig literals. - Audit every call. JSONL audit log with tool name, timestamp, and parameters — CX data calls must be answerable.
- Trim tool output. Return only the fields an agent needs; keeps context small and token cost down.
- Pick transport by deployment. stdio for desktop, HTTP/stateless for remote agents behind an OAuth gateway.
Frequently Asked Questions
Q: Why expose Qualtrics through MCP instead of calling the REST API directly?
A: Because MCP makes the platform a first-class tool for any agent: typed inputSchema, automatic discovery in Claude/Cursor, and governance (audit, rate limits) applied in one place instead of reimplemented per client. Agents get CX data without bespoke wrappers.
Q: How is Qualtrics authentication handled?
A: A Qualtrics API token issued per user, used as an OAuth 2.0 bearer-style credential and injected via environment variables. Scope the token to the narrowest role the agent needs and rotate it like any credential.
Q: Which tools are safe to expose read-first?
A: list_surveys, get_survey_responses, and search_xm_contacts are read-only and safe. trigger_distribution is the only state-changing tool and should carry a rate limit plus an audit trail, which this server does by default.
Q: Can this server run for remote agents?
A: Yes. The HTTP transport serves the same tools for remote or stateless MCP 2026-07-28 deployments, typically behind a gateway that handles OAuth 2.0 at the edge and forwards validated requests to the server.
Q: How do I keep token costs low on CX data?
A: Trim every tool response to the fields an agent actually needs, filter surveys and responses at the API level, and avoid returning full verbatim payloads unless a tool explicitly asks for them.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...