Skip to main content
MCP Deep Dive

Building Your First MCP for a SaaS

Ravinder··5 min read
MCPModel Context ProtocolAISaaSTypeScriptIntegration
Share:
Building Your First MCP for a SaaS

Theory 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:

  1. What data does the agent need to read? → Resources
  2. What actions should the agent be able to take? → Tools
  3. 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 NodeNext

Directory 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
  .env

The 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 /health endpoint 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.
Share: