🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@mdedit/mcp-server

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mdedit/mcp-server - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+97
-4
dist/index.d.ts

@@ -0,4 +1,6 @@

import { ArticleSession, AgentCredentialProvider } from '@mdedit/agent-client';
import { IArticle, PublishArticleRequest, PublishArticleResponse, PublishStatusResponse, UnpublishArticleResponse } from '@mdedit/sdk';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ArticleSession } from '@mdedit/agent-client';
import { IncomingMessage, ServerResponse } from 'node:http';
import { OAuthCredentialStore } from '@mdedit/node-auth';

@@ -21,3 +23,35 @@ type OpenArticleSession = (workspaceId: string, articleId: string) => Promise<ArticleSession>;

declare const MDEDIT_MCP_TOOL_SCOPES: {
readonly list_articles: readonly ["articles:read"];
readonly create_article: readonly ["articles:write"];
readonly read_article: readonly ["articles:read"];
readonly publish_article: readonly ["publishing:write"];
readonly get_publish_status: readonly ["publishing:read"];
readonly unpublish_article: readonly ["publishing:write"];
readonly edit_article: readonly ["articles:write"];
readonly add_comment: readonly ["articles:read", "reviews:write"];
readonly add_suggestion: readonly ["articles:read", "reviews:write"];
readonly reply_to_thread: readonly ["articles:read", "reviews:write"];
readonly resolve_thread: readonly ["articles:read", "reviews:write"];
readonly list_review_threads: readonly ["articles:read", "reviews:read"];
readonly get_presence: readonly ["articles:read"];
};
interface CreateMcpArticleInput {
collaborative: boolean;
content: string;
folderId?: string;
title: string;
workspaceId: string;
}
interface CreateMcpArticleResult extends Record<string, unknown> {
articleId: string;
content: string;
contentHash: string;
contentRevision: number;
editorUrl: string;
title: string;
workspaceId: string;
}
type MdeditMcpDependencies = {
createArticle?(input: CreateMcpArticleInput): Promise<CreateMcpArticleResult>;
listArticles(workspaceId: string): Promise<IArticle[]>;

@@ -35,3 +69,3 @@ readArticle(workspaceId: string, articleId: string): Promise<IArticle>;

agent_source: 'mcp';
transport?: 'rest' | 'stdio' | 'websocket';
transport?: 'rest' | 'stdio' | 'streamable-http' | 'websocket';
tool_name?: string;

@@ -48,3 +82,5 @@ duration_ms?: number;

type CreateMdeditMcpServerOptions = {
apiKey: string;
apiKey?: string;
availableScopes?: readonly string[];
credentialProvider?: AgentCredentialProvider;
apiUrl?: string;

@@ -55,2 +91,4 @@ agentName?: string;

telemetry?: McpTelemetry;
transport?: 'stdio' | 'streamable-http';
signal?: AbortSignal;
};

@@ -61,4 +99,59 @@ type MdeditMcpRuntime = {

};
interface CreateArticleSdk {
article: {
create(workspaceId: string, input: {
articleId: string;
title: string;
folderId?: string | null;
collaborative?: boolean;
}): Promise<unknown>;
delete(workspaceId: string, articleId: string): Promise<unknown>;
get(workspaceId: string, articleId: string): Promise<IArticle>;
saveContent(workspaceId: string, articleId: string, content: string, input?: {
baseContentRevision?: number;
contentHash?: string;
folderId?: string | null;
title?: string;
}): Promise<{
contentRevision?: number;
} | void>;
};
collaboration: {
checkpoint(workspaceId: string, articleId: string): Promise<{
packageRevision?: number;
targetHashes: Record<string, string>;
yjsRevision?: number;
}>;
};
}
declare function createDurableMcpArticle(input: CreateMcpArticleInput, { openCollaborativeSession, sdk, }: {
openCollaborativeSession: (workspaceId: string, articleId: string) => Promise<ArticleSession>;
sdk: CreateArticleSdk;
}): Promise<CreateMcpArticleResult>;
declare function createMdeditMcpServer(options: CreateMdeditMcpServerOptions): MdeditMcpRuntime;
type StreamableHttpRequest = IncomingMessage & {
body?: unknown;
};
type StreamableHttpOptionsFactory = (request: StreamableHttpRequest, signal: AbortSignal) => CreateMdeditMcpServerOptions | Promise<CreateMdeditMcpServerOptions>;
declare function createStreamableHttpHandler({ optionsForRequest, }: {
optionsForRequest: StreamableHttpOptionsFactory;
}): (request: StreamableHttpRequest, response: ServerResponse) => Promise<void>;
interface SharedCliConfig {
apiUrl?: string;
oauthProfile?: string;
oauthProfiles?: string[];
}
declare function readSharedCliOAuthConfig(environment: Record<string, string | undefined>): SharedCliConfig;
declare function createSharedOAuthCredentialProvider({ credentialStore, environment, fetch, sharedConfig, }?: {
credentialStore?: OAuthCredentialStore;
environment?: Record<string, string | undefined>;
fetch?: typeof globalThis.fetch;
sharedConfig?: SharedCliConfig;
}): Promise<{
availableScopes: string[];
credentialProvider: AgentCredentialProvider;
}>;
type McpEnvironment = Record<string, string | undefined>;

@@ -69,2 +162,2 @@ declare function optionsFromEnvironment(environment?: McpEnvironment): CreateMdeditMcpServerOptions;

export { ArticleSessionPool, type ArticleSessionPoolOptions, type CreateMdeditMcpServerOptions, type McpEnvironment, type McpTelemetry, type McpTelemetryEvent, type MdeditMcpDependencies, type MdeditMcpRuntime, type OpenArticleSession, createMdeditMcpServer, optionsFromEnvironment, runStdioServer, runStdioServerFromEnvironment };
export { ArticleSessionPool, type ArticleSessionPoolOptions, type CreateMcpArticleInput, type CreateMcpArticleResult, type CreateMdeditMcpServerOptions, MDEDIT_MCP_TOOL_SCOPES, type McpEnvironment, type McpTelemetry, type McpTelemetryEvent, type MdeditMcpDependencies, type MdeditMcpRuntime, type OpenArticleSession, type SharedCliConfig, type StreamableHttpOptionsFactory, type StreamableHttpRequest, createDurableMcpArticle, createMdeditMcpServer, createSharedOAuthCredentialProvider, createStreamableHttpHandler, optionsFromEnvironment, readSharedCliOAuthConfig, runStdioServer, runStdioServerFromEnvironment };

@@ -6,3 +6,3 @@ // src/index.ts

import { createRequire } from "module";
import { randomUUID } from "crypto";
import { createHash, randomUUID } from "crypto";
import {

@@ -16,2 +16,5 @@ normalizeApiBaseUrl,

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

@@ -140,10 +143,258 @@

};
var articleIdentityOutputSchema = z.object({
workspaceId: z.string(),
articleId: z.string()
}).catchall(z.unknown());
var articleSummaryOutputSchema = z.object({
articleId: z.string(),
workspaceId: z.string().optional(),
title: z.string().optional(),
folderId: z.string().nullable().optional(),
contentRevision: z.number().int().nonnegative().optional(),
contentHash: z.string().optional(),
collaborative: z.boolean().optional(),
createdAt: z.number().optional(),
isArchived: z.boolean().optional(),
isPinned: z.boolean().optional(),
updatedAt: z.number().optional()
}).catchall(z.unknown());
var createArticleOutputSchema = z.object({
workspaceId: z.string(),
articleId: z.string(),
title: z.string(),
content: z.string(),
contentHash: z.string(),
contentRevision: z.number().int().nonnegative(),
editorUrl: z.string().url()
}).catchall(z.unknown());
var readArticleOutputSchema = articleSummaryOutputSchema.extend({
workspaceId: z.string(),
content: z.string(),
contentRevision: z.number().int().nonnegative(),
contentHash: z.string()
});
var publishOutputSchema = articleIdentityOutputSchema.extend({
publishId: z.string(),
shortUrl: z.string().url(),
fullUrl: z.string().url(),
publishedAt: z.number()
});
var publishStatusOutputSchema = articleIdentityOutputSchema.extend({
isPublished: z.boolean(),
publishId: z.string().optional(),
shortUrl: z.string().url().optional(),
fullUrl: z.string().url().optional(),
publishedAt: z.number().optional(),
lastUpdated: z.number().optional(),
viewCount: z.number().nonnegative().optional(),
customSlug: z.string().optional(),
seoMetadata: seoMetadataSchema.optional()
});
var unpublishOutputSchema = articleIdentityOutputSchema.extend({
success: z.boolean(),
publishId: z.string()
});
var reviewMutationOutputSchema = articleIdentityOutputSchema.extend({
applied: z.boolean(),
commandId: z.string(),
events: z.array(z.unknown())
});
var MCP_TOOL_OUTPUT_SCHEMAS = {
list_articles: z.object({ articles: z.array(articleSummaryOutputSchema) }),
create_article: createArticleOutputSchema,
read_article: readArticleOutputSchema,
publish_article: publishOutputSchema,
get_publish_status: publishStatusOutputSchema,
unpublish_article: unpublishOutputSchema,
edit_article: articleIdentityOutputSchema.extend({
mode: z.string(),
content: z.string(),
contentRevision: z.number().int().nonnegative(),
contentHash: z.string(),
result: z.object({
applied: z.array(z.object({
operationIndex: z.number().int().nonnegative(),
type: z.enum(["replace", "insert", "delete"]),
range: z.object({
from: z.number().int().nonnegative(),
to: z.number().int().nonnegative()
}),
deletedText: z.string(),
insertedText: z.string()
})),
conflicts: z.array(z.unknown())
})
}),
add_comment: reviewMutationOutputSchema,
add_suggestion: reviewMutationOutputSchema,
reply_to_thread: reviewMutationOutputSchema,
resolve_thread: reviewMutationOutputSchema,
list_review_threads: articleIdentityOutputSchema.extend({
items: z.array(z.unknown())
}),
get_presence: articleIdentityOutputSchema.extend({
mode: z.string(),
participants: z.array(z.unknown())
})
};
var MDEDIT_MCP_TOOL_SCOPES = {
list_articles: ["articles:read"],
create_article: ["articles:write"],
read_article: ["articles:read"],
publish_article: ["publishing:write"],
get_publish_status: ["publishing:read"],
unpublish_article: ["publishing:write"],
edit_article: ["articles:write"],
add_comment: ["articles:read", "reviews:write"],
add_suggestion: ["articles:read", "reviews:write"],
reply_to_thread: ["articles:read", "reviews:write"],
resolve_thread: ["articles:read", "reviews:write"],
list_review_threads: ["articles:read", "reviews:read"],
get_presence: ["articles:read"]
};
function oauthToolMetadata(toolName) {
return {
outputSchema: MCP_TOOL_OUTPUT_SCHEMAS[toolName],
_meta: {
securitySchemes: [{
type: "oauth2",
scopes: [...MDEDIT_MCP_TOOL_SCOPES[toolName]]
}]
}
};
}
function exposeOpenAiToolSecuritySchemes(server) {
const protocol = server.server;
const original = protocol._requestHandlers.get("tools/list");
if (!original) throw new Error("MCP tools/list handler was not initialized");
protocol.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
const result = await original(request, extra);
return {
...result,
tools: (result.tools || []).map((tool) => ({
...tool,
// OpenAI's MCP contract currently consumes this extension at the tool
// top level. Keep the _meta mirror for standard SDK clients that strip
// unknown Tool fields while the MCP SDK catches up with the extension.
securitySchemes: tool._meta?.securitySchemes
}))
};
});
}
async function createDurableMcpArticle(input, {
openCollaborativeSession,
sdk
}) {
const articleId = randomUUID();
const expectedContentHash = createHash("sha256").update(input.content).digest("hex");
await sdk.article.create(input.workspaceId, {
articleId,
title: input.title,
folderId: input.folderId ?? null,
collaborative: input.collaborative
});
try {
let committedContentRevision;
if (input.collaborative) {
const session = await openCollaborativeSession(input.workspaceId, articleId);
try {
const current = session.read();
await session.edit([{
type: "replace",
anchor: { range: { from: 0, to: current.length } },
text: input.content
}]);
} finally {
await session.close();
}
const checkpoint = await sdk.collaboration.checkpoint(input.workspaceId, articleId);
if (checkpoint.targetHashes["content.md"] !== expectedContentHash) {
throw Object.assign(
new Error("Collaborative document did not reach the expected durable checkpoint"),
{ code: "MCP_CREATE_CHECKPOINT_MISMATCH" }
);
}
} else {
const commit = await sdk.article.saveContent(input.workspaceId, articleId, input.content, {
baseContentRevision: 0,
contentHash: expectedContentHash,
folderId: input.folderId ?? null,
title: input.title
});
committedContentRevision = commit?.contentRevision;
}
const saved = await sdk.article.get(input.workspaceId, articleId);
const savedContentRevision = typeof saved.contentRevision === "number" ? saved.contentRevision : void 0;
const contentRevision = savedContentRevision ?? committedContentRevision;
if (contentRevision === void 0) {
throw Object.assign(
new Error("Durable document did not return a content revision"),
{ code: "MCP_CREATE_REVISION_MISSING" }
);
}
return {
articleId,
content: saved.content ?? input.content,
contentHash: typeof saved.contentHash === "string" ? saved.contentHash : expectedContentHash,
contentRevision,
editorUrl: `https://app.mdedit.ai/workspaces/${encodeURIComponent(input.workspaceId)}/articles/${encodeURIComponent(articleId)}`,
title: saved.title ?? input.title,
workspaceId: input.workspaceId
};
} catch (contentError) {
try {
await sdk.article.delete(input.workspaceId, articleId);
} catch {
throw Object.assign(
new Error("Document content save failed and the retained partial document could not be removed"),
{
code: "MCP_CREATE_PARTIAL_RETAINED",
details: { articleId, retainedPartialState: true }
}
);
}
throw Object.assign(
new Error("Document content save failed; the partial document was removed"),
{
code: "MCP_CREATE_COMPENSATED",
cause: contentError,
details: { articleId, retainedPartialState: false }
}
);
}
}
function defaultDependencies(options, telemetry) {
const apiUrl = options.apiUrl ?? DEFAULT_API_URL;
const credentialProvider = options.credentialProvider ?? {
getCredential: async () => {
const apiKey = options.apiKey?.trim();
if (!apiKey) throw new TypeError("apiKey is required.");
return {
headers: { "x-api-key": apiKey },
websocketToken: apiKey
};
}
};
const sdk = createSdkClient({
baseUrlV2: normalizeApiBaseUrl(apiUrl),
getAuthHeaders: async () => ({ "x-api-key": options.apiKey })
getAuthHeaders: async () => (await credentialProvider.getCredential()).headers,
signal: options.signal
});
const agentName = options.agentName?.trim() || DEFAULT_AGENT_NAME;
const createArticle = (input) => createDurableMcpArticle(
input,
{
sdk,
openCollaborativeSession: (workspaceId, articleId) => openArticleSession({
credentialProvider,
apiUrl,
workspaceId,
articleId,
agent: { name: agentName },
signal: options.signal
})
}
);
return {
createArticle,
listArticles: (workspaceId) => sdk.article.list(workspaceId),

@@ -155,3 +406,3 @@ readArticle: (workspaceId, articleId) => sdk.article.get(workspaceId, articleId),

openSession: (workspaceId, articleId) => openArticleSession({
apiKey: options.apiKey,
credentialProvider,
apiUrl,

@@ -161,2 +412,3 @@ workspaceId,

agent: { name: agentName },
signal: options.signal,
telemetry: {

@@ -200,10 +452,10 @@ capture: (event) => telemetry.capture({

} = event;
const request = fetch(endpoint, {
const request = (async () => fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": options.apiKey
...options.credentialProvider ? (await options.credentialProvider.getCredential()).headers : { "x-api-key": options.apiKey }
},
body: JSON.stringify(payload)
}).catch(() => void 0);
}))().catch(() => void 0);
pending.add(request);

@@ -232,5 +484,21 @@ void request.finally(() => pending.delete(request));

if (article.updatedAt !== void 0) summary.updatedAt = article.updatedAt;
if (typeof article.contentRevision === "number") {
summary.contentRevision = article.contentRevision;
}
if (typeof article.contentHash === "string") summary.contentHash = article.contentHash;
if (typeof article.collaborative === "boolean") summary.collaborative = article.collaborative;
return summary;
}
function requiredContentMetadata(article, content) {
if (typeof article.contentRevision !== "number") {
throw Object.assign(
new Error("mdedit API did not return a content revision"),
{ code: "MCP_CONTENT_REVISION_MISSING" }
);
}
return {
contentHash: typeof article.contentHash === "string" ? article.contentHash : createHash("sha256").update(content).digest("hex"),
contentRevision: article.contentRevision
};
}
function success(value) {

@@ -244,3 +512,3 @@ return {

const rawMessage = error instanceof Error ? error.message : String(error);
const message = apiKey ? rawMessage.replaceAll(apiKey, "[redacted]") : rawMessage;
const message = (apiKey ? rawMessage.replaceAll(apiKey, "[redacted]") : rawMessage).replace(/\bBearer\s+\S+/gi, "Bearer [redacted]").replace(/\bmda1\.[A-Za-z0-9._-]+/g, "mda1.[redacted]").replace(/\bmdh_[A-Za-z0-9_-]+/g, "mdh_[redacted]").slice(0, 500);
return {

@@ -251,3 +519,3 @@ isError: true,

}
async function callTool(apiKey, telemetry, toolName, operation) {
async function callTool(redactionSecret, telemetry, toolName, operation, transport, signal) {
const operationId = randomUUID();

@@ -260,3 +528,3 @@ const startedAt = Date.now();

agent_source: "mcp",
transport: "stdio",
transport,
tool_name: toolName,

@@ -267,3 +535,5 @@ retry_count: 0,

try {
signal?.throwIfAborted();
const result = success(await operation());
signal?.throwIfAborted();
captureTelemetry(telemetry, {

@@ -274,3 +544,3 @@ event: "mcp_tool_completed",

agent_source: "mcp",
transport: "stdio",
transport,
tool_name: toolName,

@@ -288,3 +558,3 @@ duration_ms: Date.now() - startedAt,

agent_source: "mcp",
transport: "stdio",
transport,
tool_name: toolName,

@@ -296,7 +566,10 @@ duration_ms: Date.now() - startedAt,

});
return safeError(error, apiKey);
return safeError(error, redactionSecret ?? "");
}
}
function createMdeditMcpServer(options) {
if (!options.apiKey?.trim()) throw new TypeError("apiKey is required.");
if (!options.apiKey?.trim() && !options.credentialProvider) {
throw new TypeError("apiKey or credentialProvider is required.");
}
const transport = options.transport ?? "stdio";
const telemetry = options.telemetry ?? defaultTelemetry(options);

@@ -319,10 +592,49 @@ const dependencies = options.dependencies ?? defaultDependencies(options, telemetry);

);
const invokeTool = (toolName, operation) => {
const requiredScopes = MDEDIT_MCP_TOOL_SCOPES[toolName] || [];
const hasScope = (requiredScope) => options.availableScopes?.some(
(availableScope) => availableScope === requiredScope || requiredScope.endsWith(":read") && availableScope === `${requiredScope.slice(0, -5)}:write`
);
const missingScopes = options.availableScopes ? requiredScopes.filter((scope) => !hasScope(scope)) : [];
if (missingScopes.length > 0) {
const qualifiedScopes = missingScopes.map(
(scope) => `https://mcp.mdedit.ai/mcp/${scope}`
);
return Promise.resolve({
isError: true,
content: [{
type: "text",
text: `Additional OAuth scope required: ${missingScopes.join(", ")}`
}],
_meta: {
"mcp/www_authenticate": [
[
'Bearer resource_metadata="https://mcp.mdedit.ai/.well-known/oauth-protected-resource"',
`scope="${qualifiedScopes.join(" ")}"`,
'error="insufficient_scope"',
'error_description="Additional permission is required for this tool"'
].join(", ")
]
}
});
}
return callTool(
options.apiKey,
telemetry,
toolName,
operation,
transport,
options.signal
);
};
server.registerTool(
"list_articles",
{
title: "List Markdown Documents",
description: "List Markdown Documents in a workspace without returning their full content.",
inputSchema: z.object({ workspaceId: requiredId }).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
...oauthToolMetadata("list_articles")
},
({ workspaceId }) => callTool(options.apiKey, telemetry, "list_articles", async () => ({
({ workspaceId }) => invokeTool("list_articles", async () => ({
articles: (await dependencies.listArticles(workspaceId)).map(articleSummary)

@@ -332,11 +644,56 @@ }))

server.registerTool(
"create_article",
{
title: "Create a Markdown Document",
description: "Create a durable Markdown Document with initial content in an accessible workspace.",
inputSchema: z.object({
workspaceId: requiredId,
title: z.string().trim().min(1),
content: z.string(),
folderId: requiredId.optional(),
collaborative: z.boolean().optional().default(false)
}).strict(),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false
},
...oauthToolMetadata("create_article")
},
({ workspaceId, title, content, folderId, collaborative }) => invokeTool("create_article", async () => {
if (!dependencies.createArticle) {
throw Object.assign(
new Error("Document creation is unavailable in this MCP runtime"),
{ code: "MCP_CREATE_UNAVAILABLE" }
);
}
return dependencies.createArticle({
workspaceId,
title,
content,
folderId,
collaborative
});
})
);
server.registerTool(
"read_article",
{
title: "Read a Markdown Document",
description: "Read the current saved Markdown content and metadata for a document.",
inputSchema: articleInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
...oauthToolMetadata("read_article")
},
({ workspaceId, articleId }) => callTool(options.apiKey, telemetry, "read_article", async () => {
({ workspaceId, articleId }) => invokeTool("read_article", async () => {
const article = await dependencies.readArticle(workspaceId, articleId);
return { ...articleSummary(article), workspaceId, articleId, content: article.content ?? "" };
const content = article.content ?? "";
return {
...articleSummary(article),
workspaceId,
articleId,
content,
...requiredContentMetadata(article, content)
};
})

@@ -347,2 +704,3 @@ );

{
title: "Publish a Markdown Document",
description: "Publish or update a Markdown Document at a public mded.it link. Requires publishing:write and explicit user confirmation.",

@@ -354,5 +712,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
...oauthToolMetadata("publish_article")
},
({ workspaceId, articleId, customSlug, seoMetadata }) => callTool(options.apiKey, telemetry, "publish_article", async () => ({
({ workspaceId, articleId, customSlug, seoMetadata }) => invokeTool("publish_article", async () => ({
workspaceId,

@@ -369,7 +728,9 @@ articleId,

{
title: "Get Markdown Document publish status",
description: "Get the public-link publishing status, URL, metadata, and view count for a Markdown Document.",
inputSchema: articleInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
...oauthToolMetadata("get_publish_status")
},
({ workspaceId, articleId }) => callTool(options.apiKey, telemetry, "get_publish_status", async () => ({
({ workspaceId, articleId }) => invokeTool("get_publish_status", async () => ({
workspaceId,

@@ -383,2 +744,3 @@ articleId,

{
title: "Unpublish a Markdown Document",
description: "Disable a Markdown Document public link. Requires publishing:write and explicit user confirmation.",

@@ -388,5 +750,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
...oauthToolMetadata("unpublish_article")
},
({ workspaceId, articleId }) => callTool(options.apiKey, telemetry, "unpublish_article", async () => ({
({ workspaceId, articleId }) => invokeTool("unpublish_article", async () => ({
workspaceId,

@@ -400,2 +763,3 @@ articleId,

{
title: "Edit a Markdown Document",
description: "Apply anchored edits to a Markdown Document through a persistent live session when collaboration is enabled.",

@@ -406,7 +770,18 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
...oauthToolMetadata("edit_article")
},
({ workspaceId, articleId, operations, ifContentHash }) => callTool(options.apiKey, telemetry, "edit_article", () => pool.use(workspaceId, articleId, async (session) => {
({ workspaceId, articleId, operations, ifContentHash }) => invokeTool("edit_article", () => pool.use(workspaceId, articleId, async (session) => {
const result = await session.edit(operations, { ifContentHash });
return { workspaceId, articleId, mode: session.mode, content: session.read(), result };
const sessionContent = session.read();
const saved = await dependencies.readArticle(workspaceId, articleId);
const content = saved.content ?? sessionContent;
return {
workspaceId,
articleId,
mode: session.mode,
content,
...requiredContentMetadata(saved, content),
result
};
}))

@@ -417,2 +792,3 @@ );

{
title: "Add a review comment",
description: "Add an anchored review comment without directly changing document prose.",

@@ -424,5 +800,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
...oauthToolMetadata("add_comment")
},
({ workspaceId, articleId, anchor, body, targetId, commandId }) => callTool(options.apiKey, telemetry, "add_comment", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId, anchor, body, targetId, commandId }) => invokeTool("add_comment", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -436,2 +813,3 @@ articleId,

{
title: "Add a review suggestion",
description: "Add an anchored replacement suggestion for a human to accept or reject.",

@@ -444,5 +822,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
...oauthToolMetadata("add_suggestion")
},
({ workspaceId, articleId, anchor, replace, note, targetId, commandId }) => callTool(options.apiKey, telemetry, "add_suggestion", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId, anchor, replace, note, targetId, commandId }) => invokeTool("add_suggestion", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -456,2 +835,3 @@ articleId,

{
title: "Reply to a review thread",
description: "Reply to an existing review thread.",

@@ -463,5 +843,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
...oauthToolMetadata("reply_to_thread")
},
({ workspaceId, articleId, threadId, body, targetId, commandId }) => callTool(options.apiKey, telemetry, "reply_to_thread", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId, threadId, body, targetId, commandId }) => invokeTool("reply_to_thread", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -475,2 +856,3 @@ articleId,

{
title: "Resolve a review thread",
description: "Resolve an existing review thread.",

@@ -481,5 +863,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
...oauthToolMetadata("resolve_thread")
},
({ workspaceId, articleId, threadId, targetId, commandId }) => callTool(options.apiKey, telemetry, "resolve_thread", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId, threadId, targetId, commandId }) => invokeTool("resolve_thread", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -493,2 +876,3 @@ articleId,

{
title: "List review threads",
description: "List review comments, highlights, and suggestions for a document.",

@@ -499,5 +883,6 @@ inputSchema: articleInputSchema.extend({

}).strict(),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
...oauthToolMetadata("list_review_threads")
},
({ workspaceId, articleId, targetId, status }) => callTool(options.apiKey, telemetry, "list_review_threads", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId, targetId, status }) => invokeTool("list_review_threads", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -511,7 +896,9 @@ articleId,

{
title: "Get document presence",
description: "List the people and agents currently present in a collaborative document.",
inputSchema: articleInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
...oauthToolMetadata("get_presence")
},
({ workspaceId, articleId }) => callTool(options.apiKey, telemetry, "get_presence", () => pool.use(workspaceId, articleId, async (session) => ({
({ workspaceId, articleId }) => invokeTool("get_presence", () => pool.use(workspaceId, articleId, async (session) => ({
workspaceId,

@@ -523,2 +910,3 @@ articleId,

);
exposeOpenAiToolSecuritySchemes(server);
return {

@@ -534,2 +922,173 @@ server,

// src/oauthCredentialProvider.ts
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import {
NodeOAuthClient,
OsCredentialStore
} from "@mdedit/node-auth";
var API_RESOURCE = "https://apiv2.mdedit.ai/api";
function configPath(environment) {
return environment.MDEDIT_CONFIG_FILE || join(
environment.MDEDIT_CONFIG_DIR || join(homedir(), ".mdedit-cli"),
"config.json"
);
}
function readSharedCliOAuthConfig(environment) {
const path = configPath(environment);
if (!existsSync(path)) return {};
try {
const parsed = JSON.parse(readFileSync(path, "utf8"));
return {
apiUrl: typeof parsed.apiUrl === "string" ? parsed.apiUrl : void 0,
oauthProfile: typeof parsed.oauthProfile === "string" ? parsed.oauthProfile : void 0,
oauthProfiles: Array.isArray(parsed.oauthProfiles) ? parsed.oauthProfiles.filter((value) => typeof value === "string") : void 0
};
} catch {
throw new Error(`Shared mdedit OAuth config is invalid: ${path}`);
}
}
function apiRoot(value) {
const normalized = value.replace(/\/+$/, "");
if (normalized.endsWith("/api/v2")) return normalized.slice(0, -7);
if (normalized.endsWith("/api")) return normalized.slice(0, -4);
return normalized;
}
async function createSharedOAuthCredentialProvider({
credentialStore,
environment = process.env,
fetch: fetch2 = globalThis.fetch,
sharedConfig
} = {}) {
const config = sharedConfig || readSharedCliOAuthConfig(environment);
const profile = environment.MDEDIT_PROFILE?.trim() || config.oauthProfile?.trim() || "default";
if (!config.oauthProfiles?.includes(profile)) {
throw new Error(
`OAuth profile "${profile}" is not logged in. Run \`mdedit auth login${profile === "default" ? "" : ` --profile ${profile}`}\`.`
);
}
const root = apiRoot(
environment.MDEDIT_API_URL?.trim() || config.apiUrl?.trim() || "https://apiv2.mdedit.ai"
);
const discoveryResponse = await fetch2(`${root}/.well-known/mdedit-cli-oauth`);
if (!discoveryResponse.ok) {
throw new Error(`mdedit OAuth discovery failed (${discoveryResponse.status})`);
}
const discovery = await discoveryResponse.json();
if (!discovery.clientId || !discovery.authorizationEndpoint || !discovery.tokenEndpoint || discovery.resource !== API_RESOURCE) {
throw new Error("mdedit OAuth discovery is incomplete or has the wrong resource");
}
const store = credentialStore || new OsCredentialStore({
loadProfileIndex: async () => config.oauthProfiles || [],
saveProfileIndex: async () => void 0
});
const oauthConfiguration = {
authorizationEndpoint: discovery.authorizationEndpoint,
clientId: discovery.clientId,
redirectUri: "http://localhost:17643/oauth/callback",
resource: discovery.resource,
revocationEndpoint: discovery.revocationEndpoint,
scopes: discovery.defaultScopes,
tokenEndpoint: discovery.tokenEndpoint
};
const client = new NodeOAuthClient({
configuration: oauthConfiguration,
credentialStore: store,
fetch: fetch2
});
const tokenSet = await client.status(profile);
if (!tokenSet.authenticated) {
throw new Error(`OAuth profile "${profile}" is unavailable. Run \`mdedit auth login\`.`);
}
const scopePrefix = `${API_RESOURCE}/`;
const availableScopes = (tokenSet.scope || []).map((scope) => scope.startsWith(scopePrefix) ? scope.slice(scopePrefix.length) : scope).filter(Boolean);
const credential = async (forceRefresh) => {
const accessToken = await client.getAccessToken(profile, { forceRefresh });
return {
headers: { Authorization: `Bearer ${accessToken}` },
websocketToken: accessToken
};
};
return {
availableScopes,
credentialProvider: {
getCredential: () => credential(false),
refreshCredential: () => credential(true)
}
};
}
// src/http.ts
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
function jsonError(response, status, message) {
response.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store"
});
response.end(JSON.stringify({
jsonrpc: "2.0",
error: { code: -32e3, message },
id: null
}));
}
function createStreamableHttpHandler({
optionsForRequest
}) {
return async (request, response) => {
if (request.headers.origin) {
jsonError(response, 403, "Browser-origin MCP requests are not allowed");
return;
}
if (request.headers["mcp-session-id"]) {
jsonError(response, 400, "Mcp-Session-Id is not accepted by the stateless endpoint");
return;
}
if (request.method !== "POST") {
response.setHeader("allow", "POST");
jsonError(response, 405, "Method not allowed");
return;
}
const requestAbort = new AbortController();
const onRequestAborted = () => requestAbort.abort(
Object.assign(new Error("MCP HTTP request was cancelled"), {
code: "MCP_REQUEST_CANCELLED"
})
);
const onResponseClosed = () => {
if (!response.writableFinished) onRequestAborted();
};
request.once("aborted", onRequestAborted);
response.once("close", onResponseClosed);
try {
const requestOptions = await optionsForRequest(request, requestAbort.signal);
let runtime;
let transport;
try {
runtime = createMdeditMcpServer({
...requestOptions,
signal: requestAbort.signal,
transport: "streamable-http"
});
transport = new StreamableHTTPServerTransport({
enableJsonResponse: true,
sessionIdGenerator: void 0
});
await runtime.server.connect(transport);
await transport.handleRequest(request, response, request.body);
} catch {
if (!response.headersSent && !requestAbort.signal.aborted) {
jsonError(response, 500, "Internal MCP request failure");
}
} finally {
await transport?.close().catch(() => void 0);
await runtime?.close().catch(() => void 0);
}
} finally {
request.removeListener("aborted", onRequestAborted);
response.removeListener("close", onResponseClosed);
}
};
}
// src/index.ts

@@ -566,8 +1125,19 @@ function optionsFromEnvironment(environment = process.env) {

function runStdioServerFromEnvironment(environment = process.env) {
return runStdioServer(optionsFromEnvironment(environment));
const apiKey = environment.MDEDIT_API_KEY?.trim();
if (apiKey) return runStdioServer(optionsFromEnvironment(environment));
return createSharedOAuthCredentialProvider({ environment }).then((oauth) => runStdioServer({
...oauth,
...environment.MDEDIT_API_URL?.trim() ? { apiUrl: environment.MDEDIT_API_URL.trim() } : {},
...environment.MDEDIT_AGENT_NAME?.trim() ? { agentName: environment.MDEDIT_AGENT_NAME.trim() } : {}
}));
}
export {
ArticleSessionPool,
MDEDIT_MCP_TOOL_SCOPES,
createDurableMcpArticle,
createMdeditMcpServer,
createSharedOAuthCredentialProvider,
createStreamableHttpHandler,
optionsFromEnvironment,
readSharedCliOAuthConfig,
runStdioServer,

@@ -574,0 +1144,0 @@ runStdioServerFromEnvironment

+7
-3
{
"name": "@mdedit/mcp-server",
"version": "0.2.0",
"version": "0.2.1",
"private": false,

@@ -16,2 +16,3 @@ "type": "module",

"license": "Apache-2.0",
"mcpName": "io.github.mangoappstudio/mdedit",
"publishConfig": {

@@ -45,7 +46,9 @@ "access": "public"

"smoke:claude": "yarn --cwd ../.. workspace @mdedit/agent-client build && yarn build && node scripts/claude-smoke.mjs",
"registry:validate": "node scripts/validate-server-json.mjs",
"prepack": "yarn build"
},
"dependencies": {
"@mdedit/agent-client": "0.2.0",
"@mdedit/sdk": "0.3.0",
"@mdedit/agent-client": "1.0.0",
"@mdedit/node-auth": "0.1.0",
"@mdedit/sdk": "0.3.1",
"@modelcontextprotocol/sdk": "^1.29.0",

@@ -55,2 +58,3 @@ "zod": "^4.4.3"

"devDependencies": {
"ajv": "^6.12.6",
"@types/node": "^20.10.5",

@@ -57,0 +61,0 @@ "tsup": "^8.0.2",

# @mdedit/mcp-server
Use mdedit.ai Markdown Documents from Claude Code and other Model Context Protocol clients. The server runs locally over stdio and authenticates to mdedit with a scoped API key.
Use mdedit.ai Markdown Documents from Claude Code and other Model Context
Protocol clients. The server runs locally over stdio and authenticates with a
scoped API key. It can also share an OS-keychain OAuth profile created by
`mdedit auth login` in environments where the release-gated OAuth endpoint has
been enabled.
## Claude Code setup
Create an API key in mdedit, then add the server:
The generally available setup uses a scoped API key:

@@ -17,2 +21,5 @@ ```bash

When OAuth is enabled for your environment, `mdedit auth login` may be used
before the same `claude mcp add` command without the API-key environment value.
Reviewer agents normally use `articles:read,reviews:write`. Editing agents need `articles:write`. Accepting a suggestion also requires `articles:write` because it changes document content.

@@ -25,2 +32,3 @@ Agents that publish public links need `publishing:write`; publication status only needs

- `list_articles`
- `create_article`
- `read_article`

@@ -41,7 +49,11 @@ - `publish_article`

Live collaborative documents keep one agent session open across tool calls. The default idle timeout is 60 seconds; non-collaborative documents use the REST fallback and report an empty live-presence list.
Local stdio keeps one live collaborative session open across tool calls. The
hosted HTTP adapter uses operation-scoped sessions and does not depend on
process-local state or load-balancer stickiness. Non-collaborative documents use
the REST fallback and report an empty live-presence list.
## Environment
- `MDEDIT_API_KEY` — required mdedit API key
- `MDEDIT_API_KEY` - optional scoped API key override; takes precedence over OAuth
- `MDEDIT_PROFILE` - optional shared OAuth profile name; defaults to the active CLI profile
- `MDEDIT_API_URL` — optional API host override

@@ -48,0 +60,0 @@ - `MDEDIT_AGENT_NAME` — optional awareness display hint; defaults to `mdedit-mcp`

Sorry, the diff of this file is too big to display