Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build a Read-Write Boundary MCP Server for Agentic Office Workflows

Inspired by Microsoft's June 30, 2026 read-write agent shift, this dispatch builds rw-boundary-mcp, a FastMCP TypeScript server wrapping office/document APIs. Read tools are always available; write tools require a scope token plus an approval gate, and every mutation is logged to an append-only audit trail.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 17, 2026 Published
|
Aug 17, 2026 Updated
|
10 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Microsoft's June 30, 2026 read-write shift moved agents from reading the office to operating it, raising the stakes of every tool call.
  • The rw-boundary-mcp server makes the split structural: read tools always execute, write tools pass a scope check and an approval gate.
  • Write tools return approval intents, never completion states, so a model cannot misread 'prepared' as 'sent.'
  • Every proposal, blocked attempt, approval decision, and execution is written to an append-only audit trail.
  • OAuth scopes are checked from the token at runtime, never from a client-declared config, with audience validation and credential isolation.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

On June 30, 2026, Microsoft reset the industry's default mental model for AI agents. In an update framed around the read-write shift, the company moved its AI tools from a world where agents mostly read and summarize to a world where agents act — drafting, editing, sending, filing, and mutating state in the tools they connect to. It was the clearest statement yet that the agent era is defined not by reading the office but by operating it. That shift is a productivity gift and a security liability in equal measure, and the difference between the two is the boundary you build around every write.

This dispatch builds that boundary. You will implement rw-boundary-mcp, a FastMCP TypeScript server that wraps office and document APIs behind an explicit read-write boundary: read tools are always available to any authenticated agent, write tools require a scope token plus an approval gate, and every mutation — proposed or executed — lands in an append-only audit trail. This is the pattern Microsoft's own rollout forced every agent builder to internalize: an agent should never be one prompt away from mutating a shared document, a mailbox, or a contracts folder. If you have been wiring agents to Google Docs, Outlook, Notion, or Salesforce, the code below is your guardrail. Keep the MCP directory open while you build — you will register a server like this in it.

The read-write shift, in practical terms

Microsoft's June 30, 2026 announcement was not subtle: agentic tools in Copilot and its ecosystem moved from observation to action. An agent that once summarized your inbox can now draft and send replies; one that once parsed a contract can now propose edits to the shared copy. The latest-ai-news coverage of the shift focused on what it enables, but the engineering reality is more sobering. Every new write capability is a new mutating endpoint, and every mutating endpoint is a target. The failure mode is not exotic — a misrouted prompt that overwrites a production document, an exfiltration of a drafts folder, an agent that "helpfully" edits the wrong revision of a contract.

The industry response has been boundary engineering, and it consolidates into three rules that this server encodes. Reads are cheap and always allowed. Writes are expensive and always gated. And nothing — read or write — happens without an audit record. Microsoft's own guardrail posture followed the same shape: scope tokens for capability, human approval for consequence, and telemetry for every action. The rw-boundary-mcp server turns those three rules into a reusable FastMCP implementation. The workflows library has a sibling template for audit-driven agent behavior if you want the pipeline version of the same discipline.

Architecture

flowchart TD
    A[MCP client agent] -->|Bearer token + scopes| B[rw-boundary-mcp FastMCP server]
    B --> C{Read or write tool?}
    C -->|read| D[Read dispatcher]
    D --> E[Documents API]
    D --> F[Calendar API]
    D --> G[Mail API]
    B -->|write| H{Scope token present?}
    H -->|no| I[403 missing write scope]
    H -->|yes| J{Approval gate}
    J -->|rejected| K[Audit: mutation blocked]
    J -->|approved| L[Write executor]
    L --> M[Documents API mutation]
    L --> N[Mail API mutation]
    L --> O[Contracts folder mutation]
    L --> P[Append-only audit trail]
    E --> P
    F --> P
    G --> P

The boundary is a fork in the request path. Reads take the fast lane. Writes pass through a scope check and an approval gate that exist outside the model — the agent proposes, the gate disposes. Every leg of the flow reports to the same audit trail, which makes the server's behavior explainable after the fact and contestable in a review.

The TypeScript implementation

The server uses @modelcontextprotocol/sdk with McpServer and zod, and each tool is registered through registerTool. Reads and writes are registered in separate methods so the boundary is structural, not just documented.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

type AgentContext = {
  principalId: string;
  scopes: string[];
};

export class RwBoundaryMcp {
  private server: McpServer;
  private docs: DocumentsApi;
  private mail: MailApi;
  private audit: AuditTrail;
  private approvals: ApprovalService;

  constructor() {
    this.docs = new DocumentsApi();
    this.mail = new MailApi();
    this.audit = new AuditTrail();
    this.approvals = new ApprovalService();
    this.server = new McpServer({ name: "rw-boundary-mcp", version: "1.0.0" });
    this.registerReadTools();
    this.registerWriteTools();
  }

  private registerReadTools() {
    this.server.registerTool(
      "doc_get",
      "Read a document by ID. Always available to authenticated agents. Returns content and revision metadata.",
      { docId: z.string().describe("Document identifier") },
      async ({ docId }, extra) => {
        const ctx = this.contextFrom(extra);
        this.audit.log(ctx.principalId, "doc_get", { docId }, "read");
        return this.docs.get(docId);
      }
    );

    this.server.registerTool(
      "doc_list_changes",
      "List recent revisions of a document. Read-only change feed.",
      {
        docId: z.string().describe("Document identifier"),
        limit: z.number().int().min(1).max(50).default(10)
      },
      async ({ docId, limit }, extra) => {
        const ctx = this.contextFrom(extra);
        this.audit.log(ctx.principalId, "doc_list_changes", { docId }, "read");
        return this.docs.revisions(docId, limit);
      }
    );

    this.server.registerTool(
      "mail_search",
      "Search the mailbox read-only. Returns message headers and snippets, never full bodies.",
      {
        query: z.string().describe("Search query"),
        maxResults: z.number().int().min(1).max(20).default(10)
      },
      async ({ query, maxResults }, extra) => {
        const ctx = this.contextFrom(extra);
        this.audit.log(ctx.principalId, "mail_search", { query }, "read");
        return this.mail.search(ctx.principalId, query, maxResults);
      }
    );
  }

  private registerWriteTools() {
    this.server.registerTool(
      "doc_apply_edit",
      "PROPOSE a set of text edits to a document. Mutations require the docs:write scope token AND a human approval before execution.",
      {
        docId: z.string().describe("Document identifier"),
        baseRevision: z.number().int().describe("Revision the edits are computed against"),
        edits: z.array(z.object({
          start: z.number().int().nonnegative(),
          end: z.number().int().nonnegative(),
          replacement: z.string()
        })).describe("Ordered text edits")
      },
      async ({ docId, baseRevision, edits }, extra) => {
        const ctx = this.contextFrom(extra);
        if (!ctx.scopes.includes("docs:write")) {
          throw new Error("Missing docs:write scope");
        }
        this.audit.log(ctx.principalId, "doc_apply_edit", { docId }, "write-proposed");
        return this.approvals.createIntent("doc_edit", ctx.principalId, { docId, baseRevision, edits });
      }
    );

    this.server.registerTool(
      "mail_send_draft",
      "PREPARE a draft for sending from the principal's mailbox. Sending requires mail:send scope plus approval; nothing leaves without it.",
      {
        to: z.array(z.string()).describe("Recipients"),
        subject: z.string().describe("Subject line"),
        body: z.string().describe("Message body"),
        inReplyTo: z.string().optional().describe("Parent message ID for threading")
      },
      async ({ to, subject, body, inReplyTo }, extra) => {
        const ctx = this.contextFrom(extra);
        if (!ctx.scopes.includes("mail:send")) {
          throw new Error("Missing mail:send scope");
        }
        this.audit.log(ctx.principalId, "mail_send_draft", { to }, "write-proposed");
        return this.approvals.createIntent("mail_send", ctx.principalId, { to, subject, body, inReplyTo });
      }
    );

    this.server.registerTool(
      "mail_unsend",
      "Attempt to retract a recently sent message. Requires mail:admin scope and is logged as a high-severity mutation.",
      { messageId: z.string().describe("Sent message identifier") },
      async ({ messageId }, extra) => {
        const ctx = this.contextFrom(extra);
        if (!ctx.scopes.includes("mail:admin")) {
          throw new Error("Missing mail:admin scope");
        }
        this.audit.log(ctx.principalId, "mail_unsend", { messageId }, "write-proposed", "high");
        return this.approvals.createIntent("mail_unsend", ctx.principalId, { messageId });
      }
    );
  }

  private contextFrom(extra: any): AgentContext {
    return decodeAndValidateToken(extra?.session?.auth?.token);
  }

  async start() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
  }
}

Three choices in this code carry the design. Write tools return approval intents, never completion states, so the model cannot misread "prepared" as "sent." Every write checks the scope on the resolved token at runtime, not a client-declared scope. And the audit trail receives a proposed record before execution even happens, so blocked attempts are as visible as successful ones.

inputSchema

The tool schemas below are the JSON Schema the MCP client registers, generated from the zod definitions above. The write schemas include the fields that make mutation reviewable — baseRevision for edits, and recipient lists for mail — because an approval gate is only as good as the context it receives.

{
  "doc_get": {
    "type": "object",
    "properties": { "docId": { "type": "string" } },
    "required": ["docId"],
    "additionalProperties": false
  },
  "doc_apply_edit": {
    "type": "object",
    "properties": {
      "docId": { "type": "string" },
      "baseRevision": { "type": "integer" },
      "edits": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "start": { "type": "integer" },
            "end": { "type": "integer" },
            "replacement": { "type": "string" }
          },
          "required": ["start", "end", "replacement"]
        }
      }
    },
    "required": ["docId", "baseRevision", "edits"],
    "additionalProperties": false
  },
  "mail_send_draft": {
    "type": "object",
    "properties": {
      "to": { "type": "array", "items": { "type": "string" } },
      "subject": { "type": "string" },
      "body": { "type": "string" },
      "inReplyTo": { "type": "string" }
    },
    "required": ["to", "subject", "body"],
    "additionalProperties": false
  }
}

mcpServers config

Connect any MCP client with this block. The oauth entry requests the scope set at handshake, and the runtime enforces a subset. A read-only client should request only read scopes.

{
  "mcpServers": {
    "rw-boundary": {
      "command": "node",
      "args": ["dist/index.js"],
      "env": {
        "RW_AUTH_URL": "https://auth.example.com",
        "RW_AUDIENCE": "api.office.example.com",
        "RW_LOG_LEVEL": "info"
      },
      "oauth": {
        "authorization_url": "https://auth.example.com/authorize",
        "token_url": "https://auth.example.com/oauth/token",
        "scopes": ["docs:read", "docs:write", "mail:read", "mail:send", "mail:admin"],
        "audience": "api.office.example.com"
      }
    }
  }
}

OAuth 2.0 security

The read-write boundary is enforced through OAuth scopes, and it must be enforced at three layers, not one. Authorization: the access token's aud claim must match api.office.example.com, and the server must reject tokens minted for other APIs before dispatch. Scopes: the write dispatcher checks the token's scopes at every call; mail:send in the config block does not matter if the token does not carry it. Credential isolation: the server holds the agent's token and the upstream API's service account separately, never forwarding one identity's credential to the other provider, and upstream calls use short-lived service tokens with the narrowest scope the operation needs. PKCE is required for public clients, and because this server mutates real office state, token revocation should be immediate and audit-logged.

Retry rules

Mutation endpoints make retry policy a correctness question, not just an availability question. Reads: retry up to three times with exponential backoff (base 200 ms, factor 2, jitter 25%) on 429 and 503; all 4xx are terminal. Write-intent creation: one retry after 1 second on 5xx only; intents are idempotent via intent ID, so replays return the existing record. Approval submission: zero automatic retries — a timeout leaves the intent pending and the operator reconciles state manually. Executing a mutation the approval gate already accepted: retry at most twice on 408 and 503, always replaying the same idempotency key so a timed-out write cannot double-apply. Timeouts: reads at 10 seconds, write-intent staging at 15 seconds, execution at 30 seconds, and any operation that exceeds budget is logged as a high-severity audit event.

Frequently Asked Questions

What exactly is the read-write boundary?

A structural line in the server where read tools always execute and write tools always pass through a scope check and a human approval gate before any mutation touches an upstream API. The model never crosses it alone.

Why does Microsoft's June 30, 2026 read-write shift matter for MCP builders?

It marked the moment agents stopped being readers and became operators. That raises the consequence of every tool call, which is why the MCP community is standardizing on scope tokens and approval gates around write tools.

Does every agent need an approval gate for writes?

Every agent that mutates shared state does. A gate is cheap when the consequence is small and non-negotiable when the mutation is a document, mailbox, or contract that other humans depend on.

What belongs in the audit trail?

Every proposal, every blocked attempt, every approval decision, and every execution — with the principal, tool, target, and severity. If the trail cannot answer "who proposed, who approved, what changed," it is not an audit trail.

How do scope tokens differ from normal authentication?

Authentication proves who you are; scopes prove what you may do. The boundary relies on the token carrying the actual scopes the dispatcher checks at runtime, not a config file that claims them.

Closing thoughts

Microsoft's read-write shift on June 30, 2026 is the point where agentic tooling stopped being a reading exercise and became an operating responsibility. The rw-boundary-mcp server is the engineering answer: read tools for the always-on layer, write tools locked behind scope tokens and approval gates, and a complete audit trail underneath the whole thing. When every mutation an agent can attempt is visible, approved, and logged, acting agents stop being a liability and become the most auditable workforce you have. Build the boundary first, and let the agents loose inside it.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
A structural line in the server where read tools always execute and write tools always pass through a scope check and a human approval gate before any mutation touches an upstream API. The model never crosses it alone.
It marked the moment agents stopped being readers and became operators. That raises the consequence of every tool call, which is why the MCP community is standardizing on scope tokens and approval gates around write tools.
Every agent that mutates shared state does. A gate is cheap when the consequence is small and non-negotiable when the mutation is a document, mailbox, or contract that other humans depend on.
Every proposal, every blocked attempt, every approval decision, and every execution — with the principal, tool, target, and severity. If the trail cannot answer 'who proposed, who approved, what changed,' it is not an audit trail.
Authentication proves who you are; scopes prove what you may do. The boundary relies on the token carrying the actual scopes the dispatcher checks at runtime, not a config file that claims them.
Deepak Bagada
Author Profile

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

Briefing AI Tools

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

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

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

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc