Building Your First MCP for a SaaS
Ravinder··5 min read
MCPModel Context ProtocolAISaaSTypeScriptIntegration
Series
MCP Deep DiveTheory only carries you so far. In this post we build a real MCP server end-to-end for a fictional project management SaaS called Taskflow. By the end you will have a deployable server that exposes project data as resources, lets an agent create and update tasks via tools, and ships a reusable triage prompt. The same structure applies to any SaaS you are working on.
Capability Design Before Code
The worst way to start is to open a code editor. The best way is to answer three questions first:
- What data does the agent need to read? → Resources
- What actions should the agent be able to take? → Tools
- What reasoning patterns should the server own? → Prompts
For Taskflow:
mindmap
root((Taskflow MCP))
Resources
projects/list
projects/{id}
tasks/{id}
Tools
create_task
update_task_status
assign_task
Prompts
triage_backlog
write_sprint_summary
This design exercise takes 20 minutes and saves hours of refactoring.
Project Setup
mkdir taskflow-mcp && cd taskflow-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod express dotenv
npm install -D typescript @types/node @types/express tsx
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNextDirectory structure:
taskflow-mcp/
src/
server.ts # MCP server wiring
handlers/
tools.ts
resources.ts
prompts.ts
lib/
taskflow-api.ts # thin wrapper around Taskflow REST API
Dockerfile
.envThe API Client
Keep the SaaS API calls isolated in one place. This makes testing easy and means your MCP handlers never know about HTTP.
// src/lib/taskflow-api.ts
import "dotenv/config";
const BASE = process.env.TASKFLOW_API_URL!;
const KEY = process.env.TASKFLOW_API_KEY!;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!res.ok) throw new Error(`Taskflow API error: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
export const taskflow = {
listProjects: () => request<Project[]>("/projects"),
getProject: (id: string) => request<Project>(`/projects/${id}`),
getTask: (id: string) => request<Task>(`/tasks/${id}`),
createTask: (body: NewTask) => request<Task>("/tasks", { method: "POST", body: JSON.stringify(body) }),
updateStatus: (id: string, s: string) => request<Task>(`/tasks/${id}/status`, { method: "PATCH", body: JSON.stringify({ status: s }) }),
assignTask: (id: string, u: string) => request<Task>(`/tasks/${id}/assignee`, { method: "PATCH", body: JSON.stringify({ userId: u }) })
};Resources Handler
// src/handlers/resources.ts
import { ReadResourceRequestSchema, ListResourcesRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { taskflow } from "../lib/taskflow-api.js";
export function registerResources(server: McpServer) {
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const projects = await taskflow.listProjects();
return {
resources: projects.map(p => ({
uri: `taskflow://projects/${p.id}`,
name: p.name,
mimeType: "application/json"
}))
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const { uri } = req.params;
const projectMatch = uri.match(/^taskflow:\/\/projects\/([^/]+)$/);
const taskMatch = uri.match(/^taskflow:\/\/tasks\/([^/]+)$/);
if (projectMatch) {
const project = await taskflow.getProject(projectMatch[1]);
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(project, null, 2) }] };
}
if (taskMatch) {
const task = await taskflow.getTask(taskMatch[1]);
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(task, null, 2) }] };
}
throw new Error(`Unknown resource: ${uri}`);
});
}Tools Handler
// src/handlers/tools.ts
import { z } from "zod";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { taskflow } from "../lib/taskflow-api.js";
const CreateTaskArgs = z.object({
project_id: z.string(),
title: z.string(),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]).default("medium")
});
export function registerTools(server: McpServer) {
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "create_task",
description: "Create a new task in a Taskflow project.",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] }
},
required: ["project_id", "title"]
}
},
{
name: "update_task_status",
description: "Move a task to a new status.",
inputSchema: {
type: "object",
properties: {
task_id: { type: "string" },
status: { type: "string", enum: ["todo", "in_progress", "review", "done"] }
},
required: ["task_id", "status"]
}
}
]
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
switch (req.params.name) {
case "create_task": {
const args = CreateTaskArgs.parse(req.params.arguments);
const task = await taskflow.createTask(args);
return { content: [{ type: "text", text: `Created task ${task.id}: ${task.title}` }] };
}
case "update_task_status": {
const { task_id, status } = req.params.arguments as any;
const task = await taskflow.updateStatus(task_id, status);
return { content: [{ type: "text", text: `Task ${task.id} moved to ${task.status}` }] };
}
default:
throw new Error(`Unknown tool: ${req.params.name}`);
}
});
}Wiring It Together
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerResources } from "./handlers/resources.js";
import { registerTools } from "./handlers/tools.js";
const server = new McpServer({ name: "taskflow-mcp", version: "1.0.0" });
registerResources(server);
registerTools(server);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.get("/health", (_, res) => res.json({ status: "ok" }));
app.listen(3000, () => console.log("Taskflow MCP server on :3000"));Deployment
A minimal Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]The deployment flow:
flowchart LR
Dev["Local dev\nnpx tsx src/server.ts"] --> Build["npm run build\ntsc"]
Build --> Image["docker build\n-t taskflow-mcp ."]
Image --> Registry["Push to\nContainer Registry"]
Registry --> Deploy["Deploy to\nCloud Run / ECS / K8s"]
Deploy --> MCP["MCP endpoint\nhttps://mcp.taskflow.example.com/mcp"]
Key Takeaways
- Start with capability design — resources vs tools vs prompts — before writing any code.
- Isolate SaaS API calls in a thin client module; your MCP handlers should never touch HTTP directly.
- Use Zod for argument validation in tool handlers; it gives you runtime safety and doubles as documentation.
- Streamable HTTP transport plus a standard Express server is the production-grade default.
- A minimal Dockerfile and a
/healthendpoint are all you need to deploy to any container platform. - Structure your handlers in separate files from day one — a monolithic server.ts file will hurt you within a week.
Series
MCP Deep Dive