What if your AI assistant could list your DEV Community posts, inspect an article, and prepare a new draft without you copying content between browser tabs?
That is exactly the kind of workflow the Model Context Protocol (MCP) enables. In this tutorial, you will learn how a DEV.to MCP integration works, how to test it safely, and how to build a small version yourself with TypeScript.
The important safety rule comes first:
Let the assistant create drafts by default. Publishing should always be an explicit decision.
What we are building
Our MCP server will sit between an MCP-compatible AI client and the DEV/Forem API:
AI assistant
|
| MCP tool call
v
Local DEV.to MCP server
|
| HTTPS + API key
v
DEV Community API
We will expose three tools:
-
list_my_articles— retrieve your published or unpublished posts; -
get_article— inspect an article by ID; -
create_article_draft— save Markdown as an unpublished draft.
MCP servers may expose tools, resources, and prompts. Tools are the right primitive here because each operation has explicit inputs and may call an external API.
Prerequisites
You will need:
- a recent Node.js installation;
- a DEV Community account;
- a DEV API key from your account settings;
- an MCP-compatible client.
Create a project and install the official TypeScript SDK:
mkdir devto-mcp
cd devto-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D typescript @types/node
Add "type": "module" to package.json, plus a build command:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
A minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
Keep the API key out of the code
Store the key in an environment variable:
export DEVTO_API_KEY="your-key-here"
In PowerShell:
$env:DEVTO_API_KEY = "your-key-here"
Do not commit the key, place it in prompts, or return it from a tool. The MCP server should read it directly from its environment.
DEV currently recommends API v1. Requests use the api-key header and this media type:
application/vnd.forem.api-v1+json
Create the server
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const apiKey = process.env.DEVTO_API_KEY;
if (!apiKey) {
throw new Error("DEVTO_API_KEY is required");
}
const API_BASE = "https://dev.to/api";
async function devRequest<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
Accept: "application/vnd.forem.api-v1+json",
"Content-Type": "application/json",
"api-key": apiKey,
...init.headers,
},
});
if (!response.ok) {
const details = await response.text();
throw new Error(
`DEV API returned ${response.status}: ${details}`,
);
}
return response.json() as Promise<T>;
}
const server = new McpServer({
name: "devto",
version: "0.1.0",
});
This helper centralizes authentication, API versioning, and error handling. It also prevents every tool from implementing headers differently.
Tool 1: list your posts
The authenticated API provides separate endpoints for published, unpublished, and all articles.
server.tool(
"list_my_articles",
"List articles owned by the authenticated DEV user",
{
status: z.enum(["published", "unpublished", "all"])
.default("published"),
page: z.number().int().positive().default(1),
},
async ({ status, page }) => {
const articles = await devRequest<Array<{
id: number;
title: string;
url?: string;
published_at?: string;
published: boolean;
}>>(
`/articles/me/${status}?page=${page}&per_page=30`,
);
return {
content: [{
type: "text",
text: JSON.stringify(articles, null, 2),
}],
};
},
);
Using the authenticated /articles/me/* endpoints is better than guessing a username. It also lets the server retrieve drafts, which public profile endpoints cannot do.
Tool 2: inspect an article
server.tool(
"get_article",
"Get a DEV article by numeric ID",
{
id: z.number().int().positive(),
},
async ({ id }) => {
const article = await devRequest<unknown>(`/articles/${id}`);
return {
content: [{
type: "text",
text: JSON.stringify(article, null, 2),
}],
};
},
);
Returning structured JSON as text is simple and works across clients. For a production server, you can additionally provide structured content when your target clients support it.
Tool 3: create drafts only
Here is the most important tool in the tutorial:
server.tool(
"create_article_draft",
"Create an unpublished DEV article draft",
{
title: z.string().min(1).max(128),
body_markdown: z.string().min(1),
tags: z.array(z.string()).max(4).default([]),
},
async ({ title, body_markdown, tags }) => {
const article = await devRequest<unknown>("/articles", {
method: "POST",
body: JSON.stringify({
article: {
title,
body_markdown,
tags,
published: false,
},
}),
});
return {
content: [{
type: "text",
text: JSON.stringify(article, null, 2),
}],
};
},
);
Notice that the tool does not accept a published argument. It always sends published: false.
That is deliberate capability design. A prompt saying “please do not publish” is weaker than a tool that simply cannot publish. If you later add publishing, make it a separate tool with a clear name and require explicit user approval in the client workflow.
Start the STDIO transport
Complete the file with:
const transport = new StdioServerTransport();
await server.connect(transport);
Then build it:
npm run build
For STDIO servers, do not use console.log(). Standard output carries MCP protocol messages, so ordinary logs can corrupt the connection. Use console.error() for diagnostics.
Connect it to your MCP client
The exact configuration file depends on the client, but the process definition usually looks like this:
{
"mcpServers": {
"devto": {
"command": "node",
"args": ["/absolute/path/devto-mcp/build/index.js"],
"env": {
"DEVTO_API_KEY": "your-key-here"
}
}
}
}
Prefer a secret manager or inherited environment variable when your client supports one. A plain configuration file containing the key should never be committed.
Restart the client and verify that these tools appear:
list_my_articles
get_article
create_article_draft
A safe end-to-end test
Try this prompt:
List my published DEV articles. Then prepare a short tutorial
about this MCP integration and save it as a draft.
Do not publish it.
The assistant should:
- call
list_my_articles; - use the results as context;
- draft the Markdown;
- call
create_article_draft; - return the new article ID or edit URL.
Open your DEV dashboard and review the draft manually. Check the title, tags, links, code blocks, and claims before publishing.
Production improvements
This minimal implementation is useful, but a public MCP server should add:
- pagination controls and response-size limits;
- timeouts and retry handling for transient failures;
- clear errors without leaking request headers or secrets;
- input validation for tags and article length;
- tests with mocked HTTP responses;
- an update-draft tool that verifies ownership;
- rate-limit awareness;
- separate read and write capabilities;
- confirmation boundaries for destructive or public actions.
You should also avoid returning full article bodies when a title-and-ID summary is enough. Smaller tool responses reduce noise and make the assistant's next decision easier.
What this experiment demonstrates
MCP is not only about giving an AI “more tools.” It is about designing a controlled interface between natural-language intent and real systems.
For DEV.to, that means:
- the API key stays inside the server;
- inputs are validated;
- the assistant receives only the capabilities you expose;
- public actions can be separated from reversible draft actions;
- the user remains the final editor.
A good integration makes the safe path the easy path. Start with reading. Add draft creation. Review the behavior. Only then consider publishing or updating public content.
References
If you build your own DEV.to MCP server, I would love to hear which tools you expose—and which actions you intentionally leave out.
Top comments (1)
Making publishing a separate capability is the right design move. I would apply the same boundary one step earlier: content returned by
get_articleis untrusted input, even when it comes from your own account. A quoted prompt, hidden instruction, or compromised draft should never be allowed to redefine what the agent may do next.A production version could return article data in a clearly delimited structure, keep tool-selection policy outside model-visible content, and attach provenance such as article ID, author, publication state, and retrieval time. Then enforce the workflow in code: read calls may feed a draft generator; only the explicit draft tool may write; no fetched text can request another tool call or alter the destination account. I would also add an idempotency key to draft creation so retries do not create duplicates, plus an audit record containing the caller, normalized inputs, resulting article ID, and policy decision. “Draft-only” limits impact; provenance and deterministic orchestration keep untrusted content from steering the workflow.