Steward MCP Server

Steward MCP Server

Local Streamable HTTP MCP server that replaces the Steward Chrome extension, letting ChatGPT interact with Steward workspace data, manage drafting sessions, and generate documents via MCP tools.

Category
Visit Server

README

Steward MCP Server

Local Streamable HTTP MCP server for the Steward ChatGPT PoC — the MCP App that replaces the Steward Chrome extension as the interface.

Full plan: docs/steward-mcp-poc-plan.md. Stage docs: docs/steward-chatgpt-mcp-implementation/.

Requirements

  • Node.js >= 20.6 (uses process.loadEnvFile); developed on 22.x.

Setup

npm install
cp .env.example .env   # optional, every value has a default

Commands

Command What it does
npm run dev builds the widget, then starts the server with reload on change
npm run build builds the widget and compiles TypeScript to dist/
npm run build:ui rebuilds only the widget (the server re-reads it per request)
npm run start runs the compiled server
npm run typecheck type checks the server and the widget
npm run smoke acceptance check for the current stage (run npm run build first)
npm run tunnel opens a Cloudflare Quick Tunnel to the local server

Endpoints

Endpoint Purpose
GET /health liveness + session count
GET /stats generation reliability metrics; ?format=text for a report
POST /mcp MCP messages; a request without a session ID must be initialize
GET /mcp server → client SSE stream for an open session
DELETE /mcp terminates a session

Each Streamable HTTP session gets its own McpServer instance, so state never leaks between conversations.

Configuration

Variable Default Purpose
PORT 3000 listen port
HOST 127.0.0.1 bind address
ALLOWED_HOSTS — extra Host values for the DNS-rebinding guard, comma separated; * disables the check (see below)
GENERATION_TIMEOUT_MS 120000 how long a generation may stay open before it counts as failed
LOG_LEVEL info debug | info | warn | error
DEMO_MODE false forces fallback drafts once generation exists (stage 5+)

Logs are JSON lines on stderr. Every HTTP request carries a requestId (also returned as the x-request-id header) and every tool call logs its name, MCP session and duration via withToolLogging.

The host header guard

The MCP SDK rejects requests whose Host header is not localhost, which is what protects a local server from DNS rebinding. Put a tunnel in front and every request arrives with the tunnel's hostname instead, so it answers:

{"error":{"message":"Invalid Host: <hostname>"}}

Named tunnels: list the hostname in ALLOWED_HOSTS. Ephemeral ones such as trycloudflare.com mint a new hostname on every restart, so an allowlist is unmaintainable — set ALLOWED_HOSTS=* instead. That drops the protection: any page open in your browser can then reach the server on localhost, which is fine for local development with fixture data and not for anything else. The server logs a warning at startup whenever it runs that way.

Configuration is read once at startup, so restart the server after changing .env — tsx watch follows src/, not the environment.

Connecting to ChatGPT

ChatGPT cannot reach localhost, so development goes through a Cloudflare Quick Tunnel:

ChatGPT Web
    ↓
https://<random-name>.trycloudflare.com/mcp
    ↓
Cloudflare Tunnel
    ↓
http://localhost:3000/mcp
    ↓
Steward MCP server

cloudflared connects outbound, so no inbound port is opened and the server keeps listening on 127.0.0.1 only. The tunnel URL itself, however, is public and unauthenticated — anyone who learns it can call the tools while the tunnel is up. Fine for fixture data; stop cloudflared when you are done.

1. Install cloudflared

brew install cloudflared                          # macOS
winget install --id Cloudflare.cloudflared        # Windows
sudo apt update && sudo apt install cloudflared   # Debian/Ubuntu

If your distribution has no package, use Cloudflare's official installer for it. Verify with cloudflared --version.

2. Start the MCP server

Terminal 1:

npm run dev

Check it before going further. A plain GET /mcp returns 404 and proves nothing — the endpoint expects a POST that opens a session:

curl http://localhost:3000/health

curl -X POST http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

3. Open the tunnel

Terminal 2, and leave it running for as long as you test:

npm run tunnel

Wait for Registered tunnel connection and the assigned hostname:

Your quick Tunnel has been created!
https://random-name.trycloudflare.com

Your MCP URL is that hostname plus /mcp.

4. Allow the tunnel hostname

Quick Tunnels mint a new hostname on every restart, so .env must carry:

ALLOWED_HOSTS=*

Restart the server after changing it — configuration is read once at startup. Without this the server answers 403 Invalid Host: <hostname>; see the host header guard for what that trade-off costs.

5. Verify through the tunnel

curl https://random-name.trycloudflare.com/health      # curl.exe on Windows

Expect {"status":"ok",...}. On /mcp a plain GET may return 400/404/405 or an MCP session error — all fine. What is not fine is a Cloudflare 530 or 1033: that means the tunnel never reached your server.

6. Connect in ChatGPT

Settings → Apps → Developer mode → Create, then paste:

https://random-name.trycloudflare.com/mcp

Run Scan Tools; ping and open_steward should both appear. Then, in a chat with the connector enabled:

Use the open_steward tool
Call open_steward with funderId "acme"

The strongest confirmation is the server log rather than the panel: opening Steward and clicking Ping server must produce both a "tool":"open_steward" and a "tool":"ping" line. The second one proves the call travelled from the iframe through the host into the server.

After a tunnel restart

Cloudflare issues a new hostname every time, so the URL saved in ChatGPT stops working — update it in the app settings. ALLOWED_HOSTS=* means the server itself needs no change.

Tools and resources

Name Visible to Purpose
open_steward model, app entry point; renders ui://steward/app.html in the conversation
get_workspace model, app document types and funders; resolves a name the user said into an id
get_linked_objects model, app the opportunities linked to a funder
request_generation model, app returns the generation brief the model writes from
render_draft model stores the document the model wrote and shows it in the panel
create_session app opens a drafting session
get_session app session state, polled by the widget while generating
ping model, app connectivity probe, no Steward logic
ui://steward/app.html — resource: the single-file MCP Apps widget

Visibility comes from _meta.ui.visibility. Session plumbing is hidden from the model so it cannot wander into it, and render_draft is hidden from the widget because only the model produces drafts.

Workspace data

Fixtures are checked in as JSON under fixtures/ and loaded once at startup. test-data/ holds the CSV exports they were derived from, kept for reference.

Shapes mirror the production contracts in the extension's api-client.ts (UserDetailsResponse, LinkedOpportunity) rather than the simplified ones in the stage plan, so phase 2 swaps the loader for real endpoints and leaves the tools and widget untouched.

File Contents
funders.json 11 funders with their full CRM payload under raw
deals.json 18 opportunities, joined to funders by funderId
document-types.json 4 types with tips and systemInstructions
drafts.json pre-written drafts for demo mode, * matches anything

Editing them is a matter of editing the JSON. The one invariant to keep is that every deal.funderId matches a funder id — nothing enforces it at build time.

systemInstructions never reach the model through get_workspace — they are prompt material the server injects into the brief, not something to paraphrase.

The raw payload carries every column of the export, most of it plumbing. buildGenerationBrief projects a curated subset into the brief: giving history, contact, funder type, notes, and the opportunity's stage, amounts and next step. Passing raw wholesale would bury the few facts that change the prose.

The generation cycle

The server never calls an LLM. It hands the host model a brief and takes back a finished document:

Generate (widget)
   → request_generation   returns the brief
   → the model writes the document
   → render_draft         stores it, panel updates

render_draft is a separate call from the model to the server, so the widget is never told directly that a draft landed — it polls get_session while the status is generating.

Two orchestration variants are implemented, switchable in the widget:

Variant How it starts Trade-off
A — UI tool call the widget calls request_generation itself fewest moving parts, but the model must notice the tool result and continue unprompted
B — conversation the widget sends a follow-up message via ui/message the model drives the whole cycle, which is the path hosts are tuned for

A generation that never reaches render_draft is failed after GENERATION_TIMEOUT_MS. That deadline is evaluated when the session is read rather than by a timer, so nothing leaks and nothing can sit in generating forever.

Measuring reliability

Every attempt is recorded. GET /stats returns JSON; GET /stats?format=text prints a report:

Stage 2 — generation orchestration

overall           20 attempts   19 rendered    1 timed out    0 pending    95.0%  12.4s avg
variant A (ui)    10 attempts    8 rendered    2 timed out    0 pending    80.0%  11.8s avg
variant B (chat)  10 attempts   10 rendered    0 timed out    0 pending   100.0%  13.0s avg

The stage 2 target is ≥90% during the spike and ≥95% after tuning. Counters live in memory and reset with the process, so finish a matrix in one run.

Layout

src/
├── index.ts       entry point, signal handling, graceful shutdown
├── server.ts      express app, /health and the /mcp endpoints
├── config.ts      environment configuration
├── logger.ts      structured logging
├── paths.ts       package-root-relative paths
├── mcp/           MCP server factory, tools, resources
├── data/          fixtures loading          (stage 3)
├── store/         in-memory session store   (stage 3)
├── generation/    generation brief builder  (stage 5)
└── types/         shared domain types
ui/
├── index.html     widget shell and styles
├── src/main.ts    widget logic
└── dist/app.html  build output, served as the MCP Apps resource (git-ignored)
scripts/
├── build-ui.mjs   esbuild bundle → single self-contained HTML
└── smoke.mjs      acceptance check
fixtures/          JSON fixture data         (stage 3)

The widget

The host renders the widget in a sandboxed iframe under a strict CSP, so everything is inlined into one HTML file — no external scripts, styles or fonts. scripts/build-ui.mjs bundles ui/src/main.ts with esbuild and injects it into ui/index.html.

Inside the iframe, @modelcontextprotocol/ext-apps connects to the host over postMessage and calls MCP tools through it. The server is never contacted directly by the iframe.

Stage 4 may swap esbuild for Vite + React; the output contract — a single ui/dist/app.html — stays the same.

Current state — stage 3

The full request_generation → model writes → render_draft cycle works on fixture data, with refinement, both orchestration variants and reliability metrics. Briefs now carry the document type's own instructions plus real funder and opportunity context, so draft quality reflects what the product would actually produce.

The widget has document type, funder and opportunity pickers and surfaces the writing tips, but it is still a spike harness rather than the real interface — that is stage 4. Manual editing, version history, feedback and copy tracking are stage 5; the session store already implements them and they are covered by the smoke check ahead of the tools that expose them.

npm run smoke exercises the whole cycle server-side, standing in for the model. What it cannot check is whether a real host actually completes the cycle — that is what the matrix below is for.

Running the reliability matrix

Restart the server first so the counters start clean, then, from ChatGPT, run the documented spread per variant:

Case Runs
thank-you letters 5
grant reports 5
vague requests 5
refinements 5

Read the result with:

curl "http://localhost:3000/stats?format=text"

Checking it by hand

npm run build
npm run smoke

Or point MCP Inspector at http://127.0.0.1:3000/mcp (Streamable HTTP) while npm run dev is running.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured