agentdocs-mcp
Advanced tools
| 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: ``, | ||
| 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 | ||
| `` — 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 @@ |
+20
-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; | ||
| } |
+72
-0
@@ -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 {}; |
+4
-0
@@ -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()); |
+1
-1
@@ -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; |
+2
-0
@@ -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 @@ /** |
+52
-4
| 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); | ||
| })); | ||
| } |
+3
-3
| { | ||
| "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": [ |
+9
-3
@@ -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 |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
89851
25.78%26
8.33%1253
35.61%269
2.28%6
20%5
400%