Steward MCP Server
OfficialClick on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Steward MCP ServerShow me my open drafting sessions"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: codex-web-bridge
Setup
npm install
cp .env.example .env # optional, every value has a defaultCommands
Command | What it does |
| builds the widget, then starts the server with reload on change |
| builds the widget and compiles TypeScript to |
| rebuilds only the widget (the server re-reads it per request) |
| runs the compiled server |
| type checks the server and the widget |
| acceptance check for the current stage (run |
| opens a Cloudflare Quick Tunnel to the local server |
Endpoints
Endpoint | Purpose |
| liveness + session count |
| briefs handed to the model, by orchestration variant; |
| MCP messages; a request without a session ID must be |
| server → client SSE stream for an open session |
| terminates a session |
Each Streamable HTTP session gets its own McpServer instance, so state never
leaks between conversations.
Configuration
Variable | Default | Purpose |
|
| listen port |
|
| bind address |
| — | extra |
|
|
|
|
| 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:3003/mcp
↓
Steward MCP servercloudflared 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/UbuntuIf 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 devCheck 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:3003/health
curl -X POST http://localhost:3003/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 tunnelWait for Registered tunnel connection and the assigned hostname:
Your quick Tunnel has been created!
https://random-name.trycloudflare.comYour 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 WindowsExpect {"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/mcpRun 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 must produce both a "tool":"open_steward" and a "tool":"get_workspace"
line. The second one is the widget loading its pickers, which 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 |
| model, app | entry point; renders |
| model, app | document types, funders and opportunities; resolves a name the user said into an id |
| model, app | follows the funder ↔ opportunity link in either direction |
| model, app | returns the generation brief the model writes from |
| model | stores a finished document and opens it in its own panel |
| model, app | opens a panel listing every drafting session |
| app | opens a drafting session |
| app | session state for the panel |
| model, app | connectivity probe, no Steward logic |
| — | resource: the drafting panel |
| — | resource: one finished document |
| — | resource: the session list |
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.
Three tools carry a _meta.ui.resourceUri and open a panel of their own:
open_steward, render_draft and list_sessions. Each panel is a separate
resource, so a draft can stay on screen while the drafting form is used again.
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 |
| 11 funders with their full CRM payload under |
| 18 opportunities, joined to funders by |
| 4 types with |
| pre-written drafts for demo mode, |
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.
Funders and opportunities
Each side is a picker that adds to a row of chips, and the chips carry an × to
take one back off. Neither side gates the other: an opportunity can be the first
thing picked in an empty panel. That is why get_workspace ships the
opportunities alongside the funders — there is no funder selection to load them
from.
Adding on one side pulls the other in. Add a funder and its opportunities appear
as chips; add an opportunity and the funder behind it does. get_linked_objects
follows that link in either direction, one hop only — a funder arriving from an
opportunity does not then drag in its other opportunities. The hop runs for the
row just added rather than the whole selection, so a chip removed by hand is not
resurrected by the next add.
The server does the same join rather than trusting the panel to have done it:
request_generation accepts funderIds, dealIds, or both, and fills in the
funders behind any opportunity that arrived without one. A brief needs the
giving history to write from, and a request that names only a deal would
otherwise lose it.
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
→ the model says the draft is ready and asks whether to show it
→ the user says yes
→ render_draft stores it, the draft panel opensThe pause is deliberate, and so is where the draft lands. The model writes the
whole document first and holds it, so the user gets one short sentence rather
than several hundred words they did not ask to see yet; and when they do ask, the
document goes through render_draft into its own panel instead of being pasted
into the chat. request_generation says both in its nextStep, the tool
descriptions repeat them, and so do the server instructions — a model that stops
after the brief, or dumps the document unasked, is the failure mode worth
over-instructing against.
A session sits at briefed until a draft arrives and ready afterwards; there
is no deadline in between, because a brief with no draft may simply be an offer
the user has not answered. Refinement reuses the stored version, and
request_generation also takes an optional existingDraft for the case where
the model is holding something newer than it ever rendered.
Showing a draft again costs a sessionId and nothing else: render_draft takes
text only the first time, and re-opens the stored version without it. That is
deliberate. Asked for the draft a few turns later the model is no longer holding
the document, and a tool that demanded it back pushed the model towards the panel
that needs no arguments — which is how "show me the draft" used to open the
drafting form instead. Each tool description now names its own panel and points
at the other.
Two orchestration variants exist. The panel drives B; A is still accepted by
request_generation and exercised by the smoke check, but nothing in the widget
selects it any more:
Variant | How it starts | Trade-off |
A — UI tool call | a caller passes | fewest moving parts, but the model must notice the tool result and continue unprompted |
B — conversation | the widget sends a follow-up message via | the model drives the whole cycle, which is the path hosts are tuned for |
Measuring reliability
GET /stats returns JSON; GET /stats?format=text prints a report:
Stage 2 — generation orchestration
overall 20 briefs 19 rendered 4 refinements 95.0% 12.4s avg
variant A (ui) 10 briefs 8 rendered 2 refinements 80.0% 11.8s avg
variant B (chat) 10 briefs 10 rendered 2 refinements 100.0% 13.0s avgRead the rate as how often the cycle completed, not as a reliability score: a brief without a draft can equally be an offer the user declined or has not answered yet. What the numbers cannot see at all is whether the model paused to ask — that is read off the chat. 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/
├── styles.css the shared stylesheet, inlined into every widget at build time
├── index.html drafting panel shell → src/main.ts
├── draft.html draft panel shell → src/draft.ts
├── sessions.html session list shell → src/sessions.ts
└── dist/*.html build output, served as the MCP Apps resources (git-ignored)
scripts/
├── build-ui.mjs esbuild bundle → one self-contained HTML per widget
└── smoke.mjs acceptance check
fixtures/ JSON fixture data (stage 3)The widgets
There are three, one per panel:
Panel | Opened by | What it does |
|
| the drafting form: document type, funders, opportunities, request |
|
| one finished document, with its version and word count |
|
| every session in the workspace, newest first, with a draft preview |
The host renders each 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 each entry point with esbuild and injects it,
along with the shared ui/styles.css, into that widget's template. The
stylesheet is shared at build time rather than copied per template: three panels
that should look like one product must not carry three drifting sets of tokens.
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.
How a panel gets its data differs by what it needs. The draft panel reads the
render_draft arguments straight off the ui/notifications/tool-input the host
sends — the document is already in the call, so the common path needs no round
trip; it falls back to get_session for hosts that deliver only the result. The
sessions panel takes no arguments, so it calls list_sessions itself on connect
and on every Refresh, since the result that opened it may already be stale.
Stage 4 may swap esbuild for Vite + React; the output contract — one
self-contained ui/dist/<name>.html per widget — stays the same.
Current state — stage 3
The request_generation → model writes → model offers → render_draft cycle
works on fixture data, with refinement and both orchestration variants. Briefs
carry the document type's own instructions plus real funder and opportunity
context, so draft quality reflects what the product would actually produce.
The drafting panel has a document type picker, chip-based funder and opportunity selection, and surfaces the writing tips; the draft and session panels are one screen each. All three are still spike harnesses 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 everything the server can be held to: the brief, both
session states, render_draft, the session listing, all three widget resources,
and the instruction the model receives. What no server-side check can reach is
the half that happens in the conversation — whether the model writes the
document, offers it, and waits. That is what the matrix below is for, and it is
read by eye.
Running the reliability matrix
Restart the server first so the counters start clean, then, from ChatGPT, run the documented spread through the panel (variant B). For each run, note by hand whether the model wrote the document, offered it in one short sentence, and waited for the user before printing it:
Case | Runs |
thank-you letters | 5 |
grant reports | 5 |
vague requests | 5 |
refinements | 5 |
Read the brief counts with:
curl "http://localhost:3003/stats?format=text"They tell you how many briefs each variant produced, not how many cycles finished — that number comes from your own tally of the chats.
Checking it by hand
npm run build
npm run smokeOr point MCP Inspector at http://127.0.0.1:3003/mcp (Streamable HTTP) while
npm run dev is running.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
MCP server that lets AI assistants use all OneSchema features exposed via the public API.
Remote MCP server for supportsheep: run AI interviews and manage support content for your blog.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Remote MCP server exposing SMI Aware tools, resources, and skills over Streamable HTTP.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenancePrivate OAuth-backed MCP server for ChatGPT, supporting GPT Apps via MCP Streamable HTTP and GPT Actions via REST endpoints with OpenAPI 3.1.
- AlicenseNot gradedqualityCmaintenanceLocal MCP server bridging ChatGPT Web to local tools for file, shell, git, test, and process management with secure policy controls.MIT
- FlicenseNot gradedqualityBmaintenanceZero-dependency MCP server that provides AI models with secure read/write/exec access to local files and directories over HTTP and SSE, designed to be tunneled via ngrok for integration with Claude Web.
- AlicenseNot gradedqualityBmaintenanceAn OAuth-protected MCP server exposing a coding-tool harness over Streamable HTTP, enabling remote MCP clients like ChatGPT to securely access a local development environment.16MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/american-journalism-project/steward-mcp-poc'
If you have feedback or need assistance with the MCP directory API, please join our Discord server