TL;DR
On May 25, 2026, PromptArmor disclosed that Microsoft 365 Copilot Cowork was exfiltrating SharePoint and OneDrive files through a 5-line indirect prompt injection hidden inside an 81-line skill file. Attack success rate: 5 of 5 trials against Claude Opus 4.7, model-agnostic. Most "build an MCP server in TypeScript" tutorials published in 2026 — including the one ranking #1 on Google today — ship the exact pattern Copilot got burned on: tool outputs flowing into the model with no sanitization layer, no allow-listed action approvals, and stdout used for both protocol frames and logging. This post is the secure build, using the official MCP TypeScript SDK v1.29.0 — stdio for local, streamable HTTP for production, plus the indirect-prompt-injection defense that should be in every server you ship in 2026.
Build a Secure MCP Server in TypeScript — The Post-Copilot Defense Playbook (2026)
By Rohit Raj — Founding Engineer · 10+ yrs MVP shipping · LinkedIn
I have shipped Spring Boot MCP servers (here's the Java one) into client MVPs for the last six months, and watched the MCP TypeScript ecosystem move faster than any framework I have built on since Next.js 13's app-router rewrite. The Anthropic-published SDK is now at v1.29.0 as of March 30, 2026, with v2 in pre-alpha and v1.x still the explicit prod recommendation per the official typescript-sdk README.
What changed last week is the threat model. The PromptArmor disclosure on May 25 is not a Microsoft-only bug — it is a class of bug that every MCP server is exposed to the moment a tool output flows back into the model context. The HN thread hit 259 points in 12 hours because the indie dev crowd realised the same five-line payload would work against MCP servers they had shipped to clients last quarter.
This post is the build I wish had existed when I started. Setup, tool registration, transport choice, deployment, and the four defense layers most tutorials skip.
What is Actually New in the MCP TypeScript Ecosystem (May 2026)
Three concrete shifts since the freecodecamp handbook (last updated June 25, 2025) went stale:
- Transport story finalised. v1.29.0 ships three transports — stdio, SSE (deprecated, will be removed in v2), and streamable HTTP (the production-grade one, with Express and Hono middleware adapters). Most 2025 tutorials still show SSE.
- Standard Schema replaces raw Zod. v1.27+ accepts any Standard Schema-compliant validator — Zod, Valibot, Effect Schema, or ArkType. Zod is still the path of least resistance but it is no longer a hard requirement.
- The OWASP Top 10 for Agentic AI Applications, published December 2025, ranks Agent Goal Hijacking (ASI01) — i.e. indirect prompt injection — as the #1 risk for agent systems. The MCP TypeScript SDK itself ships no built-in defense for this. That gap is on you, the server author.
If your last tutorial was older than 90 days, throw out the transport setup and re-read.
Step 1 — Project Setup (Node 18+, the Real Dependencies)
Bring up a Node 18+ project. Node 20 is the floor for production because the streamable-HTTP transport uses AbortSignal.any() which landed in Node 19.
In package.json set "type": "module" and add scripts:
The one mistake every first-timer makes: console.log to stdout inside a stdio server. The MCP wire protocol uses stdout for JSON-RPC frames; a stray log corrupts framing and the client silently disconnects. Always log to stderr with console.error, or use a structured logger like Pino with destination: 2. The official SDK README puts this in caps for a reason.
Step 2 — Register Tools with Zod Schemas (the Build)
Create src/server.ts. This server exposes two tools: search_company (read-only) and send_summary_email (side-effecting — note the marker, it matters in Step 5).
Wire it into Claude Desktop's claude_desktop_config.json:
Relative paths fail silently. This is the #2 first-timer mistake — Claude Desktop's config resolves paths from the desktop binary's working directory, not yours.
Step 3 — Transports: stdio vs SSE vs Streamable HTTP
Pick the wrong transport and you either cannot deploy at all (stdio) or you ship a deprecated path (SSE). The honest comparison:
| Dimension | stdio | SSE (legacy) | Streamable HTTP |
|---|---|---|---|
| SDK status | Stable, v1.x + v2 | Deprecated in v1.29, removed in v2 | Stable since v1.21, recommended for prod |
| Where it runs | Local subprocess only | HTTP server | HTTP server (Express, Hono, raw node:http) |
| Auth model | Implicit (parent process) | Header-based | Header-based, supports OAuth proxy |
| Multi-tenant | No | Yes | Yes |
| Resumable streams | N/A | No | Yes (server-initiated requests) |
| Cloud-deployable | No | Yes | Yes |
| First-time setup | 30 seconds | 5 minutes | 10 minutes |
| What 80% of 2025 tutorials show | Yes | Yes | No |
If you are building a server for personal use on a developer machine — stdio is fine and faster to ship. If you are shipping to a client, streamable HTTP. Skip SSE — it is on the v2 chopping block and porting effort is real once you have auth wired around it.
The streamable-HTTP variant, in 30 lines:
Behind a reverse proxy (Nginx, Cloudflare, Caddy), terminate TLS, require an Authorization: Bearer <jwt> header, and you have a multi-tenant server.
Step 4 — Anatomy of the Copilot Exfil (and Why Most Tutorials Reproduce the Hole)
The PromptArmor disclosure laid out the exact attack pattern. It works in three moves:
- Plant: attacker drops a skill file (81 lines, looks legitimate) into a shared workspace. 5 of those 81 lines are the injection — instructions to find pre-authenticated SharePoint download links and embed them in a Teams message as HTML
<img>tags. - Trigger: the user invokes the skill with something innocuous like "summarise my week from these docs."
- Exfiltrate: the compromised agent fetches files via Microsoft Graph, drops the pre-authenticated URLs into a Teams DM to the user themselves, and Teams' image-prefetching fires the URLs as GETs to the attacker's server. No human approval was prompted because the design flaw classified "send a message to the active user" as not requiring confirmation.
The same primitives are present in nearly every MCP server I have read in 2026:
- Tool outputs are returned as opaque text, which the model then treats as instructions if those instructions are persuasive enough.
- Side-effecting tools (send email, post to Slack, open a PR) execute without re-confirmation when the destination is "the user themselves."
- Stdout is shared between protocol frames and any incidental string the tool returns.
The agensi.io top-ranked TypeScript tutorial, the freecodecamp handbook, and the dev.to "30-minute setup" all stop at "register a tool, return some text." None of them install a single defense layer. Shipping that pattern to a client in 2026 is now negligent. Treat every tool output as untrusted text from the open internet — because if your tool fetches docs, scrapes a URL, or reads a shared workspace, that is exactly what it is.
Step 5 — The Four Defense Layers Every MCP Server Should Ship With
Layer them. Each one alone fails — together they cut indirect-prompt-injection risk to near zero in the threat model I have actually faced on client builds.
Layer 1 — Allow-list side-effecting tools and require explicit elicitation.
Use the MCP SDK's elicitInput to prompt the user before any tool that writes, sends, or transfers data. Do not rely on the host (Claude Desktop, Cursor, Cowork) to ask — the host's UX may classify the action as low-risk. The Copilot bug was exactly this: Microsoft's UI did not ask because the recipient was the user, but the file URLs leaked anyway.
Layer 2 — Sanitise tool outputs before they re-enter the model context.
Strip HTML, neutralise instruction-looking payloads, and tag external content explicitly. A 30-line helper:
This is exactly the defense Microsoft's own engineers later wrote up after the disclosure. The Cowork bug shipped without it.
Layer 3 — Pattern-match for known injection signatures on tool output.
Cheap, fast, blocks the 90% case. Maintain a rule list (start with the OWASP AAI Top 10 examples) and reject or flag matches before they reach the model:
For higher-stakes servers, layer in a small classifier model (a 1-2B fine-tune) downstream of pattern matching — Practical DevSecOps and Mindgard both publish open-source weights for this.
Layer 4 — Stdout discipline plus structured logging.
console.error only. Pipe stderr to a file or your observability backend. Treat process.stdout.write as a syntax error in code review — it is, for MCP servers. I keep a one-line ESLint rule for it:
Together these four layers add roughly 60 lines of code and one config file to the basic build above. That is the entire cost of not shipping the next Copilot bug to your client.
Step 6 — Deploying to Production (Fly.io, Railway, or a VPS)
For client MVPs I default to Fly.io or Hetzner CPX21 (€5.30/mo) for the streamable-HTTP variant. The whole flow:
Wire the public URL into the host's MCP config:
Production checklist (the one I run before going live for a client — none of the top tutorials ship this):
- Stdout is silent on the prod path.
docker logsshould show only stderr entries. - All side-effecting tools call `elicitInput`.
- All tool inputs use Zod or another Standard Schema validator with explicit
.min()/.max()and.describe()strings. - Tool outputs from external fetches pass through `quarantine()`.
- Injection-pattern matcher runs on every tool output, with alerts when a pattern fires (this is your canary).
- Health check at `GET /health` that does not require auth.
- Rate limit per session — the SDK does not do this for you. Express middleware or a Redis token bucket, 50-100 req/min per JWT subject is a reasonable starting point.
- Structured audit log of every tool call:
{ userId, toolName, argsHash, durationMs, status }. Searchable later when something looks off.
This is not paranoid. It is the table-stakes I would expect any senior engineer hiring a founding engineer for an AI MVP in India to demand of their first MCP server in production.
When You Should Skip This and Use Spring Boot or Python Instead
Honest counter-position. TypeScript MCP is the right pick if:
- You are integrating with an existing Node/Bun/Deno codebase.
- You want the lightest possible deploy footprint (10-20 MB container).
- Your tools are mostly IO-bound HTTP fetches — Node's event loop is the right tool.
Use the Java/Spring Boot MCP build instead if you are sitting on top of an existing Spring service, need Spring Security wired in for free, or your tools are CPU-bound and benefit from JVM warm-up. Use Python's MCP SDK if you are doing heavy ML inference and want HuggingFace, sentence-transformers, or vLLM in the same process — Python's ML ecosystem is a generation ahead of Node's.
The defense layers in Step 5 are language-agnostic; the patterns port one-to-one. The transport choice is what changes.
What you should not do is reach for one of the closed-source "MCP gateway" SaaS offerings before you have shipped your own server once. You will not understand what you are paying for, and the marketing pages conflate "we run your MCP server" with "we secure your MCP server." Those are different products. After the Copilot disclosure I am skeptical of any vendor pitching the latter without showing exactly which of the four layers above they implement.
How I Would Ship This in a 6-Week MVP Today
Concrete plan for a client AI feature that needs an MCP server, on the 6-week timeline I run these MVP sprints on:
- Week 1: scaffold the streamable-HTTP server from Step 3, deploy a placeholder behind a Cloudflare-fronted Fly app, wire health check + auth. Two tools registered as no-ops returning canned text. Goal: end-to-end from Claude Desktop → my server → my Postgres, before the first feature lands.
- Week 2: implement the read-only tools (search, get-by-id). Sanitise all outputs through
quarantine()from day one. Write integration tests that send an injected payload and assert the model never executes it. - Week 3: side-effecting tools, each gated by
elicitInput. Add the injection-pattern matcher and wire alerts to a Slack webhook. - Week 4: rate limit, structured audit log, observability (a
mcp_tool_invocationtable in Postgres, plus traces to Honeycomb's free tier). This is the week the "but who's watching this thing in prod" question gets a real answer. - Week 5: red-team my own server. I literally try to exfiltrate via every tool. Two of my last three builds had bugs I caught only this way — including one where a tool's error message accidentally echoed the system prompt.
- Week 6: hand off. Documentation that includes the defense-layer checklist above. The client knows what they are getting and what their next senior engineer should keep in place.
The 6-week timeline is tight, but the security work is not what blows the budget. It is 4-6 hours total across the schedule. The blown-budget version is the one where you skip the security work, ship to prod in week 5, and spend week 7 doing incident response after a curious user feeds an injection-laden PDF into your knowledge tool. Ask anyone who shipped a Cowork integration in the last 60 days.
Wrap-Up
The MCP TypeScript SDK at v1.29.0 is the cleanest API I have built tools against in a long time. Zod schemas, three transport choices, server-initiated requests, and a clean elicitation primitive — all in a few-hundred-KB install. The build is genuinely easy.
What is not easy is shipping a server that will not have its tool outputs weaponised against your users. That part has not been documented yet — the SDK leaves it to you, and the top tutorials skip it entirely. Fix that on your own server before your client finds the bug for you. The Microsoft Copilot disclosure is the warning you get for free.
If you want a second pair of eyes on an MCP server that is going into production this quarter — or you need someone to do the build the secure way the first time around — I do that. 6-week MVPs and founding-engineer engagements both include the defense-layer checklist above as table stakes.
