New:Socket for Asana Is Now Available.Learn more
Get Started

agentdocs-mcp

Package Overview
Dependencies
Maintainers
1
Versions
20
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

agentdocs-mcp - npm Package Compare versions

Comparing version
0.9.3
to
0.10.0
+3
dist/tools/media.d.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ToolContext } from "../context.js";
export declare function registerMediaTools(server: McpServer, ctx: ToolContext): void;
import { z } from "zod";
import { readFile } from "node:fs/promises";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { safe, textResult } from "../context.js";
const MAX_BYTES = 5 * 1024 * 1024; // matches the server's per-file cap
/** MIME types the server accepts. SVG is excluded there (script injection). */
const SNIFFERS = [
{ mime: "image/png", ext: "png", match: b => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
{ mime: "image/jpeg", ext: "jpg", match: b => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
{ mime: "image/gif", ext: "gif", match: b => b.subarray(0, 6).toString("ascii").startsWith("GIF8") },
{
mime: "image/webp",
ext: "webp",
match: b => b.subarray(0, 4).toString("ascii") === "RIFF" && b.subarray(8, 12).toString("ascii") === "WEBP",
},
];
/**
* Identify the image from its magic bytes rather than trusting a filename.
* The server sniffs nothing — it believes the declared Content-Type — so
* getting this right here is what keeps a mislabelled file from being stored
* under a type it isn't.
*/
function sniffImage(bytes) {
const hit = SNIFFERS.find(s => s.match(bytes));
if (!hit) {
throw new Error("Unsupported image format. AgentDocs accepts PNG, JPEG, GIF and WebP (SVG is rejected server-side as a script-injection vector).");
}
return { mime: hit.mime, ext: hit.ext };
}
function isPrivateAddress(ip) {
if (ip.startsWith("127.") || ip === "::1" || ip === "0.0.0.0")
return true;
if (ip.startsWith("10.") || ip.startsWith("192.168."))
return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip))
return true;
if (ip.startsWith("169.254."))
return true; // link-local, incl. cloud metadata
if (/^f[cd]/i.test(ip))
return true; // IPv6 unique-local
if (/^fe80:/i.test(ip))
return true; // IPv6 link-local
return false;
}
/**
* Fetch an image by URL, refusing anything that resolves to a private address.
*
* This matters more on the remote surface than it looks: there this code runs
* inside AgentDocs' own backend, so an unguarded fetch would be a server-side
* request forgery primitive pointed at the production network and its cloud
* metadata endpoint.
*/
async function fetchImage(rawUrl) {
let url;
try {
url = new URL(rawUrl);
}
catch {
throw new Error(`source_url is not a valid URL: ${rawUrl}`);
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("source_url must be http or https.");
}
const host = url.hostname.replace(/^\[|\]$/g, "");
const addresses = isIP(host) ? [host] : (await lookup(host, { all: true })).map(a => a.address);
if (addresses.length === 0)
throw new Error(`Could not resolve ${url.hostname}.`);
if (addresses.some(isPrivateAddress)) {
throw new Error(`Refusing to fetch ${url.hostname}: it resolves to a private or loopback address.`);
}
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(20_000) });
if (!response.ok)
throw new Error(`Fetching ${rawUrl} failed with HTTP ${response.status}.`);
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > MAX_BYTES) {
throw new Error(`Image is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the limit is 5 MB.`);
}
return bytes;
}
export function registerMediaTools(server, ctx) {
const { client, resolver } = ctx;
const localFiles = ctx.capabilities?.localFiles === true;
// The description differs by surface so the model is not offered an argument
// that will be refused.
const sources = localFiles
? 'Provide exactly one of "path" (a file on this machine), "source_url", or "data" (base64).'
: 'Provide exactly one of "source_url" or "data" (base64). "path" is unavailable on the remote server — it has no access to your filesystem.';
server.registerTool("upload_image", {
title: "Upload image",
description: "Attach an image (PNG, JPEG, GIF or WebP; max 5 MB) to a space and get back the Markdown to embed it in a page. " +
"Use this to include screenshots and diagrams in the pages you write, so whoever reads the page later — human or agent — can see what you saw. " +
sources +
" Counts against the workspace's storage quota.",
inputSchema: {
space: z
.string()
.optional()
.describe('Space UUID or "workspaceSlug/spaceSlug" path. Optional for space-scoped tokens.'),
path: z
.string()
.optional()
.describe(localFiles
? "Absolute path to an image file on this machine."
: "Not supported on the remote server; use source_url or data."),
source_url: z.string().optional().describe("Public http(s) URL to fetch the image from."),
data: z.string().optional().describe("Base64-encoded image bytes."),
filename: z.string().optional().describe("Original filename to record (cosmetic; defaults to image.<ext>)."),
alt_text: z.string().optional().describe("Alt text for the returned Markdown snippet."),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, safe(async ({ space, path, source_url, data, filename, alt_text }) => {
const given = [path, source_url, data].filter(v => v !== undefined && v !== "");
if (given.length === 0) {
throw new Error(`No image source given. ${sources}`);
}
if (given.length > 1) {
throw new Error("Give only one of path, source_url or data.");
}
let bytes;
if (path !== undefined && path !== "") {
if (!localFiles) {
throw new Error("This AgentDocs server cannot read files from your machine — it is a remote HTTP endpoint. " +
"Pass the image as base64 via `data`, or host it and pass `source_url`.");
}
bytes = await readFile(path);
if (bytes.length > MAX_BYTES) {
throw new Error(`Image is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the limit is 5 MB.`);
}
}
else if (source_url !== undefined && source_url !== "") {
bytes = await fetchImage(source_url);
}
else {
bytes = Buffer.from(data, "base64");
if (bytes.length === 0)
throw new Error("data did not decode to any bytes — is it valid base64?");
if (bytes.length > MAX_BYTES) {
throw new Error(`Image is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the limit is 5 MB.`);
}
}
const { mime, ext } = sniffImage(bytes);
const spaceId = await resolver.spaceId(space);
const name = filename || (path ? path.split(/[\\/]/).pop() : undefined) || `image.${ext}`;
const result = await client.uploadFile(`/api/spaces/${spaceId}/uploads`, { bytes, filename: name, mimeType: mime });
const absolute = `${client.baseUrl}${result.url}`;
return textResult({
...result,
absolute_url: absolute,
markdown: `![${alt_text || name}](${result.url})`,
next_step: "Paste the `markdown` value into a page (create_page / update_page / append_to_page) to display the image there.",
});
}));
}
+32
-0

@@ -7,2 +7,34 @@ # Changelog

## 0.10.0 — 2026-08-22
### Added
- **`upload_image` — agents can finally attach images.** Until now an agent could
write a page *about* a screenshot but had no way to include it: the REST API had
`POST /api/uploads`, and none of the 18 tools wrapped it. Takes `path` (stdio
only — see below), `source_url`, or base64 `data`; returns the URL plus
ready-to-paste Markdown. Format is identified from magic bytes rather than the
filename, and SVG is refused because the server rejects it as a script-injection
vector. Uploads count against the workspace's image storage quota, so an
over-quota call surfaces the usual tier-limit message with the upgrade path.
- **`get_page` gained `include_images`.** The other half of the same problem:
`get_page` returns Markdown, so an image on a page reached the reader as
`![](/api/uploads/x.png)` — a string it could not see. With `include_images: true`
the embedded images come back as MCP image content blocks (max 5). Off by default
so ordinary reads stay cheap. Only AgentDocs-hosted `/api/uploads/` URLs are
fetched; arbitrary URLs found in page content are deliberately left alone.
### Security
- **`ToolContext.capabilities.localFiles` gates local file access.** These tool
definitions are shared with AgentDocs' backend, which registers them per request
on `POST /mcp`. `upload_image`'s `path` argument reads the filesystem of whatever
machine the server runs on — correct for stdio, arbitrary file read on the
production host if honoured remotely. The stdio entry point sets it true; the
remote endpoint sets it false; **an absent `capabilities` object defaults to
refusing paths**, so a forgetful caller fails closed. Covered by tests on both
sides of the boundary.
- `source_url` refuses URLs resolving to loopback, private, or link-local
addresses (including the cloud metadata endpoint) — on the remote surface an
unguarded fetch would be a server-side request forgery primitive inside
AgentDocs' own network.
## 0.9.3 — 2026-08-09

@@ -9,0 +41,0 @@

@@ -18,3 +18,23 @@ import type { Config } from "./config.js";

request<T = Record<string, unknown>>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", path: string, body?: unknown, query?: Record<string, string>): Promise<T>;
/**
* Upload one file as multipart/form-data.
*
* Hand-builds the body as a Buffer rather than using FormData/Blob. That is
* NOT stylistic: on the remote surface this request is dispatched in-process
* by AgentDocs' backend (backend/src/mcp/inProcessFetch.js), which only
* special-cases Buffer and otherwise does Buffer.from(String(init.body)) —
* a FormData would be stringified to "[object FormData]" and the upload would
* silently arrive as garbage.
*/
uploadFile<T = Record<string, unknown>>(path: string, file: {
bytes: Buffer;
filename: string;
mimeType: string;
}, query?: Record<string, string>): Promise<T>;
/** Fetch raw bytes (an uploaded image) rather than JSON. */
fetchBinary(path: string): Promise<{
bytes: Buffer;
mimeType: string;
}>;
private fetchWithColdStartRetry;
}

@@ -0,1 +1,2 @@

import { randomBytes } from "node:crypto";
/**

@@ -102,2 +103,73 @@ * Neon free tier suspends after ~5 min idle and takes 10-15s to wake; the

}
/**
* Upload one file as multipart/form-data.
*
* Hand-builds the body as a Buffer rather than using FormData/Blob. That is
* NOT stylistic: on the remote surface this request is dispatched in-process
* by AgentDocs' backend (backend/src/mcp/inProcessFetch.js), which only
* special-cases Buffer and otherwise does Buffer.from(String(init.body)) —
* a FormData would be stringified to "[object FormData]" and the upload would
* silently arrive as garbage.
*/
async uploadFile(path, file, query) {
const url = new URL(`${this.config.baseUrl}${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value);
}
const boundary = `----agentdocs${randomBytes(16).toString("hex")}`;
const safeName = file.filename.replace(/[\r\n"]/g, "_");
const head = Buffer.from(`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${safeName}"\r\n` +
`Content-Type: ${file.mimeType}\r\n\r\n`);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, file.bytes, tail]);
const init = {
method: "POST",
headers: {
Authorization: this.authorization,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body: body,
};
let response;
try {
response = await this.fetchWithColdStartRetry(url, init);
}
catch (err) {
throw new Error(`Could not reach ${this.config.baseUrl} (${err instanceof Error ? err.message : String(err)}). ` +
`The server may be waking from idle (first request can take ~15s) — retry in a moment.`);
}
const text = await response.text();
let json = {};
if (text) {
try {
json = JSON.parse(text);
}
catch {
if (!response.ok) {
throw new ApiError(response.status, {}, `AgentDocs API error ${response.status} (non-JSON response)`);
}
}
}
if (!response.ok) {
throw new ApiError(response.status, json, friendlyMessage(response.status, json, this.config.baseUrl));
}
return json;
}
/** Fetch raw bytes (an uploaded image) rather than JSON. */
async fetchBinary(path) {
const url = new URL(`${this.config.baseUrl}${path}`);
const doFetch = this.config.fetchImpl ?? fetch;
const response = await doFetch(url, {
method: "GET",
headers: { Authorization: this.authorization },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok)
throw new ApiError(response.status, {}, `Could not read ${path} (HTTP ${response.status}).`);
return {
bytes: Buffer.from(await response.arrayBuffer()),
mimeType: response.headers.get("content-type") ?? "application/octet-stream",
};
}
async fetchWithColdStartRetry(url, init) {

@@ -104,0 +176,0 @@ const doFetch = this.config.fetchImpl ?? fetch;

+29
-5

@@ -12,2 +12,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

}
/**
* What the surface running these tools is physically able to do.
*
* `localFiles` is a SECURITY gate, not a convenience flag. registerAllTools is
* shared by the stdio binary (running on the user's own machine, where reading
* a path they named is the whole point) and by AgentDocs' backend, which
* registers the same tools per request on POST /mcp. A `path` argument honoured
* there would be arbitrary file read on the production server, so the remote
* surface MUST set this false.
*/
export interface ToolCapabilities {
localFiles: boolean;
}
export interface ToolContext {

@@ -17,14 +30,25 @@ client: AgentDocsClient;

credential: CredentialInfo;
/** Defaults to no local file access — the safe assumption for a remote host. */
capabilities?: ToolCapabilities;
}
type TextContent = {
type: "text";
text: string;
};
/** Base64-encoded bytes, per the MCP image content block. */
type ImageContent = {
type: "image";
data: string;
mimeType: string;
};
type ToolResult = {
content: Array<{
type: "text";
text: string;
}>;
content: Array<TextContent | ImageContent>;
isError?: boolean;
};
export declare function textResult(data: unknown): ToolResult;
/** A text block followed by image blocks, for tools that return both. */
export declare function textWithImages(data: unknown, images: ImageContent[]): ToolResult;
/** Wrap a tool handler so thrown errors surface as readable MCP tool errors. */
export declare function safe<Args>(handler: (args: Args) => Promise<ToolResult>): (args: Args) => Promise<ToolResult>;
export type { TextContent, ImageContent };
export type RegisterFn = (server: McpServer, ctx: ToolContext) => void;
export {};

@@ -5,2 +5,6 @@ export function textResult(data) {

}
/** A text block followed by image blocks, for tools that return both. */
export function textWithImages(data, images) {
return { content: [...textResult(data).content, ...images] };
}
/** Wrap a tool handler so thrown errors surface as readable MCP tool errors. */

@@ -7,0 +11,0 @@ export function safe(handler) {

+3
-1

@@ -54,3 +54,5 @@ #!/usr/bin/env node

const resolver = new Resolver(client, credential.type !== "space", credential.spaceId);
const ctx = { client, resolver, credential };
// stdio runs on the user's own machine: reading a path they explicitly
// named is the point. The remote /mcp surface sets this false.
const ctx = { client, resolver, credential, capabilities: { localFiles: true } };
const server = createMcpServer(ctx, VERSION);

@@ -57,0 +59,0 @@ await server.connect(new StdioServerTransport());

@@ -15,3 +15,3 @@ /**

export type { Config } from "./config.js";
export type { CredentialInfo, ToolContext } from "./context.js";
export type { CredentialInfo, ToolContext, ToolCapabilities } from "./context.js";
/** Register the full AgentDocs tool set on an MCP server instance. */

@@ -18,0 +18,0 @@ export declare function registerAllTools(server: McpServer, ctx: ToolContext): void;

@@ -15,2 +15,3 @@ /**

import { registerCommentTools } from "./tools/comments.js";
import { registerMediaTools } from "./tools/media.js";
export { AgentDocsClient, ApiError } from "./client.js";

@@ -24,2 +25,3 @@ export { Resolver, isUuid } from "./resolve.js";

registerCommentTools(server, ctx);
registerMediaTools(server, ctx);
}

@@ -26,0 +28,0 @@ /**

import { z } from "zod";
import { safe, textResult } from "../context.js";
import { safe, textResult, textWithImages } from "../context.js";
/** Cap on images returned per page: they are large and share the model's context. */
const MAX_INLINE_IMAGES = 5;
/**
* Pull AgentDocs-hosted images out of a page body so they can be returned as
* viewable image blocks.
*
* Only /api/uploads/ URLs are followed. External images are deliberately left
* alone: fetching arbitrary URLs named in page content would turn a read into a
* server-side request forgery primitive on the remote surface, where this code
* runs inside AgentDocs' own backend.
*/
async function collectPageImages(client, content) {
const refs = [...content.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)]
.map(m => m[1])
.map(u => {
const at = u.indexOf("/api/uploads/");
return at === -1 ? null : u.slice(at);
})
.filter((u) => u !== null && /^\/api\/uploads\/[A-Za-z0-9._-]+$/.test(u));
const unique = [...new Set(refs)];
const notes = [];
if (unique.length === 0) {
notes.push("No AgentDocs-hosted images found in this page (external image URLs are not fetched).");
return { images: [], notes };
}
const take = unique.slice(0, MAX_INLINE_IMAGES);
if (unique.length > take.length) {
notes.push(`Page references ${unique.length} images; returning the first ${take.length}.`);
}
const images = [];
for (const ref of take) {
try {
const { bytes, mimeType } = await client.fetchBinary(ref);
images.push({ type: "image", data: bytes.toString("base64"), mimeType });
}
catch (err) {
notes.push(`Could not load ${ref}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { images, notes };
}
/**
* Drop page content from listings to keep tool output small. Comment stats are

@@ -109,3 +150,3 @@ * kept (when non-zero) — comments don't bump updated_at, so they are the only

title: "Get page",
description: "Read a page including its full Markdown content and current version number. The page carries comment_count / unresolved_comment_count / last_comment_at — if comment_count > 0 there is a discussion; set include_comments to read it. include_children returns the page's child pages (titles + slugs, no content) — useful for 'folder' pages whose own content is empty but which organise sub-pages.",
description: "Read a page including its full Markdown content and current version number. The page carries comment_count / unresolved_comment_count / last_comment_at — if comment_count > 0 there is a discussion; set include_comments to read it. include_children returns the page's child pages (titles + slugs, no content) — useful for 'folder' pages whose own content is empty but which organise sub-pages. include_images returns any images embedded in the page as viewable image blocks, so you can actually SEE a screenshot the page references instead of only its URL.",
inputSchema: {

@@ -121,5 +162,9 @@ page: z.string().describe('Page UUID or "workspaceSlug/spaceSlug/pageSlug" path'),

.describe("When true, also return the page's immediate child pages (id, title, slug — no content)."),
include_images: z
.boolean()
.optional()
.describe(`When true, also return images embedded in the page as image blocks you can view (max ${MAX_INLINE_IMAGES}). Off by default — images are large, so ordinary reads stay cheap.`),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, safe(async ({ page, include_comments, include_children }) => {
}, safe(async ({ page, include_comments, include_children, include_images }) => {
const pageId = await resolver.pageId(page);

@@ -130,4 +175,7 @@ const include = [include_comments && "comments", include_children && "children"]

const result = await client.request("GET", `/api/pages/${pageId}`, undefined, include ? { include } : undefined);
return textResult(result);
if (!include_images)
return textResult(result);
const { images, notes } = await collectPageImages(client, result.page?.content ?? "");
return textWithImages(notes.length ? { ...result, image_notes: notes } : result, images);
}));
}
{
"name": "agentdocs-mcp",
"mcpName": "io.github.hoornet/agentdocs-mcp",
"version": "0.9.3",
"description": "MCP server for AgentDocs (agentdocs.eu) — read, search, and write collaborative docs from any MCP client",
"version": "0.10.0",
"description": "MCP server for AgentDocs (agentdocs.eu) \u2014 read, search, and write collaborative docs from any MCP client",
"license": "MIT",

@@ -41,3 +41,3 @@ "author": "Jure (https://agentdocs.eu)",

"watch": "tsc --watch",
"test:unit": "npm run build && node --test test/resolve.test.mjs"
"test:unit": "npm run build && node --test test/resolve.test.mjs test/media.test.mjs"
},

@@ -44,0 +44,0 @@ "keywords": [

@@ -39,3 +39,3 @@ <p align="center">

Any client that speaks remote MCP can use the hosted endpoint directly; there's no package
to install and nothing to keep updated. Same 18 tools as the stdio server.
to install and nothing to keep updated. Same 19 tools as the stdio server.

@@ -179,3 +179,3 @@ ```

> agentdocs-mcp isn't listed there yet. Use the **hosted remote endpoint**
> instead: `https://agentdocs.eu/mcp` (Streamable HTTP, same 18 tools, nothing
> instead: `https://agentdocs.eu/mcp` (Streamable HTTP, same 19 tools, nothing
> to install) — see [Remote](#remote-hosted--nothing-to-install) above. Failing

@@ -214,3 +214,3 @@ > that, the [REST API](https://agentdocs.eu/llms.txt) has full parity.

| `semantic_search` | Natural-language search ranked by meaning — Pro workspaces ¹ |
| `get_page` | Read a page (full Markdown + version); optional `include_comments` / `include_children` |
| `get_page` | Read a page (full Markdown + version); optional `include_comments` / `include_children` / `include_images` (returns embedded images as viewable image blocks) |
| `create_page` | Create a Markdown page (nestable) |

@@ -227,5 +227,11 @@ | `update_page` | Update title/content, with optional optimistic version check |

| `delete_comment` | Delete a comment (author/admin) |
| `upload_image` | Attach a PNG/JPEG/GIF/WebP to a space and get Markdown to embed it — from `path` ², `source_url`, or base64 `data` |
¹ Hidden when running with a space-scoped token.
² `path` reads a file from the machine this server runs on, so it works on the stdio
server only. The hosted `agentdocs.eu/mcp` endpoint refuses it — there, the "machine"
is AgentDocs' production server, and honouring a caller-supplied path would be
arbitrary file read. Use `source_url` or `data` there.
Pages, spaces, and workspaces are addressable by UUID **or** human-readable slug

@@ -232,0 +238,0 @@ path — `get_page` accepts `"my-workspace/my-space/my-page"`, `create_page` accepts