From d20517dbe379cdc51e290dcaf54142620de2982b Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 12:25:36 +0200 Subject: [PATCH 1/2] feat: expose product docs through MCP --- packages/core/bin/cli.mjs | 1 + packages/core/bin/run.js | 1 + packages/mcp/src/output-schemas.js | 1 + packages/mcp/src/schemas.js | 31 +++++++++++++++ packages/mcp/src/tools/docs.js | 18 +++++++++ packages/mcp/src/tools/index.js | 2 + packages/mcp/src/tools/register.js | 1 + packages/mcp/test/docs.test.js | 60 ++++++++++++++++++++++++++++++ 8 files changed, 115 insertions(+) create mode 100644 packages/mcp/src/tools/docs.js create mode 100644 packages/mcp/test/docs.test.js diff --git a/packages/core/bin/cli.mjs b/packages/core/bin/cli.mjs index 934e664..f591356 100644 --- a/packages/core/bin/cli.mjs +++ b/packages/core/bin/cli.mjs @@ -6,3 +6,4 @@ const cli = require('./run.js') export default cli export const run = cli.run export const helpText = cli.helpText +export const docs = cli.docs diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 7178f3d..d0eaf59 100644 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -180,3 +180,4 @@ const run = async (argvInput, host) => { module.exports = run module.exports.run = run module.exports.helpText = helpText +module.exports.docs = docs diff --git a/packages/mcp/src/output-schemas.js b/packages/mcp/src/output-schemas.js index 0b5ac14..1725371 100644 --- a/packages/mcp/src/output-schemas.js +++ b/packages/mcp/src/output-schemas.js @@ -124,6 +124,7 @@ export const outputSchemas = { list_plans: plansSchema, create_checkout_session: checkoutSessionSchema, get_checkout_session: checkoutStatusSchema, + docs: z.string(), metadata: metadataSchema, logo: nullableAssetSchema, markdown: stringSchema, diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index b545165..1143a6d 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -456,6 +456,37 @@ export const functionInputSchema = baseSchema }) .strict() +export const DOC_PRODUCTS = [ + 'metadata', + 'logo', + 'markdown', + 'html', + 'text', + 'video', + 'audio', + 'emails', + 'links', + 'images', + 'videos', + 'audios', + 'extract', + 'screenshot', + 'pdf', + 'embed', + 'technologies', + 'lighthouse', + 'search', + 'function' +] + +export const docsInputSchema = z + .object({ + product: z.enum(DOC_PRODUCTS, { + error: `Unknown product. Valid products: ${DOC_PRODUCTS.join(', ')}.` + }) + }) + .strict() + export const listPlansInputSchema = z.object({}).strict() export const createCheckoutSessionInputSchema = z diff --git a/packages/mcp/src/tools/docs.js b/packages/mcp/src/tools/docs.js new file mode 100644 index 0000000..8d4c246 --- /dev/null +++ b/packages/mcp/src/tools/docs.js @@ -0,0 +1,18 @@ +import { docs as productDocs } from 'microlink.io/cli' + +import { docsInputSchema } from '../schemas.js' +import { register } from './register.js' + +export function docs (server) { + register( + server, + 'microlink_docs', + [ + 'Fetch the canonical, complete parameter documentation for a Microlink product.', + 'Call this before using a product tool whose parameters you do not know well.', + 'Returns the product markdown directly from microlink.io, the same source used by `microlink docs`.' + ].join(' '), + docsInputSchema, + (_client, { product }) => productDocs.load(product) + ) +} diff --git a/packages/mcp/src/tools/index.js b/packages/mcp/src/tools/index.js index 889f16c..0910472 100644 --- a/packages/mcp/src/tools/index.js +++ b/packages/mcp/src/tools/index.js @@ -2,6 +2,7 @@ import { audio } from './audio.js' import { checkoutCreate } from './create-checkout-session.js' import { checkoutStatus } from './get-checkout-session.js' import { audios } from './audios.js' +import { docs } from './docs.js' import { emails } from './emails.js' import { embed } from './embed.js' import { extract } from './extract.js' @@ -26,6 +27,7 @@ export function tools (server) { plans(server) checkoutCreate(server) checkoutStatus(server) + docs(server) metadata(server) logo(server) markdown(server) diff --git a/packages/mcp/src/tools/register.js b/packages/mcp/src/tools/register.js index 2e1e641..ca52092 100644 --- a/packages/mcp/src/tools/register.js +++ b/packages/mcp/src/tools/register.js @@ -59,6 +59,7 @@ const TITLES = { list_plans: 'Plans', create_checkout_session: 'Create checkout session', get_checkout_session: 'Checkout session status', + docs: 'Product docs', metadata: 'Metadata', logo: 'Logo', markdown: 'Markdown', diff --git a/packages/mcp/test/docs.test.js b/packages/mcp/test/docs.test.js new file mode 100644 index 0000000..4a49c90 --- /dev/null +++ b/packages/mcp/test/docs.test.js @@ -0,0 +1,60 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { docs } from '../src/tools/docs.js' + +function captureDocs () { + let handler + docs({ + registerTool: (_name, _config, registeredHandler) => { + handler = registeredHandler + } + }) + return handler +} + +function stubFetch (t, implementation) { + const originalFetch = globalThis.fetch + t.after(() => { + globalThis.fetch = originalFetch + }) + globalThis.fetch = implementation +} + +test('microlink_docs returns the canonical product markdown', async t => { + stubFetch(t, async (href, options) => { + assert.equal(href, 'https://microlink.io/docs/sdk/methods/screenshot.md') + assert.ok(options.signal instanceof AbortSignal) + return new Response('# screenshot\n') + }) + + const result = await captureDocs()({ product: 'screenshot' }, {}) + + assert.equal(result.isError, false) + assert.equal(result.structuredContent.data, '# screenshot\n') +}) + +test('microlink_docs rejects unknown products with the valid choices', async () => { + const result = await captureDocs()({ product: 'unknown' }, {}) + const error = JSON.parse(result.content[0].text) + + assert.equal(result.isError, true) + assert.equal(result.structuredContent, undefined) + assert.equal(error.message, 'Input validation failed.') + assert.match(error.issues[0].message, /Unknown product/) + assert.match(error.issues[0].message, /metadata/) + assert.match(error.issues[0].message, /function/) +}) + +test('microlink_docs surfaces fetch failures', async t => { + stubFetch(t, async () => { + throw new Error('network unavailable') + }) + + const result = await captureDocs()({ product: 'markdown' }, {}) + const error = JSON.parse(result.content[0].text) + + assert.equal(result.isError, true) + assert.equal(result.structuredContent, undefined) + assert.equal(error.message, 'network unavailable') +}) From f4f14d10e8d32085f1bbd705d10324b3aee1a92a Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 11:01:32 +0200 Subject: [PATCH 2/2] refactor: load product docs from microlink.io/docs MCP was importing the CLI runner just to fetch markdown. Co-authored-by: Cursor --- packages/core/bin/cli.mjs | 1 - packages/core/bin/docs.js | 25 ++++++++++++++++++- packages/core/bin/run.js | 1 - packages/core/package.json | 3 ++- packages/core/test/docs.mjs | 7 +++++- packages/mcp/README.md | 24 +++++++++++++++---- packages/mcp/src/index.js | 1 + packages/mcp/src/output-schemas.js | 9 +++---- packages/mcp/src/schemas.js | 32 +++++-------------------- packages/mcp/src/tools/docs.js | 4 ++-- packages/mcp/src/tools/register.js | 7 +++--- packages/mcp/test/docs.test.js | 12 ++++++++++ packages/mcp/test/stdio-server.test.js | 2 +- packages/mcp/test/tools.test.js | 33 +++----------------------- 14 files changed, 86 insertions(+), 75 deletions(-) diff --git a/packages/core/bin/cli.mjs b/packages/core/bin/cli.mjs index f591356..934e664 100644 --- a/packages/core/bin/cli.mjs +++ b/packages/core/bin/cli.mjs @@ -6,4 +6,3 @@ const cli = require('./run.js') export default cli export const run = cli.run export const helpText = cli.helpText -export const docs = cli.docs diff --git a/packages/core/bin/docs.js b/packages/core/bin/docs.js index 783d4c7..1e8eaff 100644 --- a/packages/core/bin/docs.js +++ b/packages/core/bin/docs.js @@ -1,5 +1,28 @@ 'use strict' +const products = [ + 'metadata', + 'logo', + 'markdown', + 'html', + 'text', + 'video', + 'audio', + 'emails', + 'links', + 'images', + 'videos', + 'audios', + 'extract', + 'screenshot', + 'pdf', + 'embed', + 'technologies', + 'lighthouse', + 'search', + 'function' +] + const url = product => `https://microlink.io/docs/sdk/methods/${product}.md` const load = async (product, fetchFn = fetch) => { @@ -9,4 +32,4 @@ const load = async (product, fetchFn = fetch) => { return res.text() } -module.exports = { load, url } +module.exports = { load, url, products } diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 74b0cd4..2311625 100644 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -184,4 +184,3 @@ const run = async (argvInput, host) => { module.exports = run module.exports.run = run module.exports.helpText = helpText -module.exports.docs = docs diff --git a/packages/core/package.json b/packages/core/package.json index f9e0c06..90baf2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,8 @@ "import": "./bin/cli.mjs", "require": "./bin/run.js", "default": "./bin/cli.mjs" - } + }, + "./docs": "./bin/docs.js" }, "bin": { "microlink": "bin/index.js", diff --git a/packages/core/test/docs.mjs b/packages/core/test/docs.mjs index fb5726f..09af761 100644 --- a/packages/core/test/docs.mjs +++ b/packages/core/test/docs.mjs @@ -2,12 +2,17 @@ import { createRequire } from 'module' import test from 'ava' const require = createRequire(import.meta.url) -const { load, url } = require('../bin/docs') +const create = require('../src') +const { load, url, products } = require('../bin/docs') test('points at the SDK method markdown file', t => { t.is(url('markdown'), 'https://microlink.io/docs/sdk/methods/markdown.md') }) +test('products match the library methods', t => { + t.deepEqual([...products].sort(), Object.keys(create()).sort()) +}) + test('load fetches the markdown file', async t => { const text = await load('markdown', (href, opts) => { t.is(href, url('markdown')) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 018b691..cc304a1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -123,8 +123,9 @@ Once the server is configured, talk to your assistant in plain language. It pick - *"Find the playable video in this YouTube link."* → `microlink_video` - *"Run a Lighthouse performance audit on https://example.com."* → `microlink_lighthouse` - *"Scrape every article title from this page using the `.title` selector."* → `microlink_extract` with `data` +- *"What parameters does screenshot take?"* → `microlink_docs` -Tools can also be invoked directly. URL-processing tools take a `url`; onboarding tools use the inputs listed below. Every tool returns `structuredContent` (see [Response shape](#response-shape)): +Tools can also be invoked directly. URL-processing tools take a `url`; onboarding tools use the inputs listed below; `microlink_docs` takes a `product`. Every tool returns `structuredContent` (see [Response shape](#response-shape)): ```json { @@ -141,11 +142,12 @@ Tools can also be invoked directly. URL-processing tools take a `url`; onboardin ### Capabilities at a glance -URL-processing tools are thin wrappers over a [`microlink.io`](https://github.com/microlinkhq/microlink/tree/master/packages/core) library method — same inputs, same result, one source of truth. Onboarding tools call the public dashboard Checkout API instead. +URL-processing tools are thin wrappers over a [`microlink.io`](https://github.com/microlinkhq/microlink/tree/master/packages/core) library method — same inputs, same result, one source of truth. Onboarding tools call the public dashboard Checkout API instead. `microlink_docs` loads canonical product markdown from microlink.io (the same source as `microlink docs`). - `microlink_list_plans`: list plans available to a new customer. - `microlink_create_checkout_session`: create an idempotent subscription Checkout Session. Give its `checkoutUrl` to the human. - `microlink_get_checkout_session`: poll checkout state until `ready` or `expired`. `ready` includes `keyId` (a non-secret key handle); the API key secret is not returned here (welcome email / dashboard). +- `microlink_docs`: canonical parameter docs for a product. Call this before a product tool whose parameters you do not know well. - `microlink_metadata`: normalized metadata extraction with include/exclude config. - `microlink_logo`: brand logo extraction. - `microlink_markdown` / `microlink_html` / `microlink_text`: URL to Markdown / HTML / plain text. @@ -163,8 +165,8 @@ URL-processing tools are thin wrappers over a [`microlink.io`](https://github.co ### Response shape -- URL-processing tools return the library's **direct result** under `structuredContent.data` (and the same value as pretty-printed JSON text). For example `microlink_markdown` → `{ data: "# Title\n..." }`, `microlink_screenshot` → `{ data: { url, type, width, height, size } }`, `microlink_links` → `{ data: ["https://...", ...] }`. Onboarding tools return dashboard Checkout payloads under the same `structuredContent.data` envelope. -- Every tool also declares an MCP `outputSchema` describing its `structuredContent.data`. URL-processing schemas mirror the TypeScript types shipped by the library (`Asset`, `Metadata`, `Embed`, `FunctionResult`, ...); onboarding schemas mirror the dashboard Checkout API. Error results are exempt from output validation. Fields that can legitimately be absent are nullable (for example `logo` when no brand logo is detected, or `markdown` when the selector matches nothing). +- URL-processing tools return the library's **direct result** under `structuredContent.data` (and the same value as pretty-printed JSON text). For example `microlink_markdown` → `{ data: "# Title\n..." }`, `microlink_screenshot` → `{ data: { url, type, width, height, size } }`, `microlink_links` → `{ data: ["https://...", ...] }`. Onboarding tools return dashboard Checkout payloads under the same envelope. `microlink_docs` returns the product markdown string. +- Every tool also declares an MCP `outputSchema` describing its `structuredContent.data`. URL-processing schemas mirror the TypeScript types shipped by the library (`Asset`, `Metadata`, `Embed`, `FunctionResult`, ...); onboarding schemas mirror the dashboard Checkout API; `docs` is a markdown string. Error results are exempt from output validation. Fields that can legitimately be absent are nullable (for example `logo` when no brand logo is detected, or `markdown` when the selector matches nothing). - Tools are annotated `readOnlyHint: true` when they only read remote state. `microlink_function` executes user-supplied code and `microlink_create_checkout_session` creates remote Checkout state, so they are not annotated read-only. - On failure the tool sets MCP `isError` and returns `{ error: { message, code?, status?, statusCode?, url?, more?, details? } }`, where `message` carries the specific cause reported by the API. Capability errors that retrying cannot fix (for example `EPROXYNEEDED` or `EINTEGRATION`) also include machine-readable `reason` (`upgrade_required`), `capability`, an `upgrade` object with the plan and pricing URL, and an agent-facing `hint` with the next step. A `429` includes `reason: "quota_exceeded"` and a free-quota `hint`. @@ -173,6 +175,20 @@ For compatibility with some MCP clients: - boolean parameters also accept the strings `"true"` and `"false"` and are normalized before validation. - parameters that accept objects also accept JSON stringified objects (for example, `screenshot: "{\"overlay\":{\"browser\":\"dark\"}}"`). +### `microlink_docs` + +Fetch the canonical parameter documentation for a Microlink product. Returns the same markdown as `microlink docs` (`https://microlink.io/docs/sdk/methods/.md`). + +Call this before using a product tool whose parameters you do not know well. + +**Key parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `product` | `string` | Product name *(required)*. One of: `metadata`, `logo`, `markdown`, `html`, `text`, `video`, `audio`, `emails`, `links`, `images`, `videos`, `audios`, `extract`, `screenshot`, `pdf`, `embed`, `technologies`, `lighthouse`, `search`, `function` | + +--- + ### `microlink_extract` Extract structured metadata from any public URL. Returns normalized fields (`title`, `description`, `author`, `publisher`, `date`, `image`, `logo`, `lang`, `url`) plus any custom fields defined via CSS selectors. diff --git a/packages/mcp/src/index.js b/packages/mcp/src/index.js index d111202..d3af2c1 100644 --- a/packages/mcp/src/index.js +++ b/packages/mcp/src/index.js @@ -11,6 +11,7 @@ const { version: pkgVersion } = require('../package.json') const DEFAULT_INSTRUCTIONS = [ 'Turn any public URL into screenshots, PDFs, metadata, readable content (Markdown, HTML or plain text), media sources, technology stacks, Lighthouse audits, Google search results or custom-scraped fields.', 'Always pass full URLs including the protocol.', + "If you are unsure of a product tool's parameters, call microlink_docs first.", 'Without an API key, requests use the free endpoint (50 requests/day); pass apiKey or set MICROLINK_API_KEY for PRO.', 'On failure, read the error message and the hint/reason fields and adjust the request instead of retrying blindly.' ].join(' ') diff --git a/packages/mcp/src/output-schemas.js b/packages/mcp/src/output-schemas.js index 1725371..b253f3e 100644 --- a/packages/mcp/src/output-schemas.js +++ b/packages/mcp/src/output-schemas.js @@ -1,10 +1,11 @@ import { z } from 'zod' // Output schemas for every tool's `structuredContent.data` value. -// They mirror the TypeScript definitions that ship with the library -// (packages/core/src/index.d.ts, packages/search/src/index.d.ts), which are -// the canonical contract for response shapes. Index signatures map to -// `.catchall(z.unknown())` so forward-compatible API fields always validate. +// URL-processing schemas mirror the TypeScript definitions that ship with +// the library (packages/core/src/index.d.ts, packages/search/src/index.d.ts). +// `docs` is the product markdown string from microlink.io. Index signatures +// map to `.catchall(z.unknown())` so forward-compatible API fields always +// validate. // `Asset` (packages/core/src/index.d.ts). const assetSchema = z diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index 1143a6d..39229a7 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -1,3 +1,4 @@ +import { products as DOC_PRODUCTS } from 'microlink.io/docs' import { z } from 'zod' const stringOrStringArraySchema = z.union([ @@ -456,34 +457,13 @@ export const functionInputSchema = baseSchema }) .strict() -export const DOC_PRODUCTS = [ - 'metadata', - 'logo', - 'markdown', - 'html', - 'text', - 'video', - 'audio', - 'emails', - 'links', - 'images', - 'videos', - 'audios', - 'extract', - 'screenshot', - 'pdf', - 'embed', - 'technologies', - 'lighthouse', - 'search', - 'function' -] - export const docsInputSchema = z .object({ - product: z.enum(DOC_PRODUCTS, { - error: `Unknown product. Valid products: ${DOC_PRODUCTS.join(', ')}.` - }) + product: z + .enum(DOC_PRODUCTS, { + error: `Unknown product. Valid products: ${DOC_PRODUCTS.join(', ')}.` + }) + .describe('Microlink product whose canonical parameter docs to fetch.') }) .strict() diff --git a/packages/mcp/src/tools/docs.js b/packages/mcp/src/tools/docs.js index 8d4c246..ed02fca 100644 --- a/packages/mcp/src/tools/docs.js +++ b/packages/mcp/src/tools/docs.js @@ -1,4 +1,4 @@ -import { docs as productDocs } from 'microlink.io/cli' +import { load as loadProductDocs } from 'microlink.io/docs' import { docsInputSchema } from '../schemas.js' import { register } from './register.js' @@ -13,6 +13,6 @@ export function docs (server) { 'Returns the product markdown directly from microlink.io, the same source used by `microlink docs`.' ].join(' '), docsInputSchema, - (_client, { product }) => productDocs.load(product) + (_client, { product }) => loadProductDocs(product) ) } diff --git a/packages/mcp/src/tools/register.js b/packages/mcp/src/tools/register.js index ca52092..de86c00 100644 --- a/packages/mcp/src/tools/register.js +++ b/packages/mcp/src/tools/register.js @@ -40,9 +40,10 @@ function getApiKeyFromRequestHeaders (headers) { return undefined } -// Every tool is a remote read against the Microlink API: it never modifies -// the caller's environment. `microlink_function` is the exception: it runs -// caller-supplied code against the live page, so it is not declared read-only. +// Tools are remote reads and never modify the caller's environment. Product +// tools hit the Microlink API; `microlink_docs` fetches public markdown from +// microlink.io. `microlink_function` is the exception: it runs caller-supplied +// code against the live page, so it is not declared read-only. const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, diff --git a/packages/mcp/test/docs.test.js b/packages/mcp/test/docs.test.js index 4a49c90..e3cb148 100644 --- a/packages/mcp/test/docs.test.js +++ b/packages/mcp/test/docs.test.js @@ -58,3 +58,15 @@ test('microlink_docs surfaces fetch failures', async t => { assert.equal(result.structuredContent, undefined) assert.equal(error.message, 'network unavailable') }) + +test('microlink_docs surfaces HTTP fetch failures', async t => { + stubFetch(t, async () => new Response('missing', { status: 404 })) + + const result = await captureDocs()({ product: 'markdown' }, {}) + const error = JSON.parse(result.content[0].text) + + assert.equal(result.isError, true) + assert.equal(result.structuredContent, undefined) + assert.match(error.message, /Failed to fetch/) + assert.match(error.message, /404/) +}) diff --git a/packages/mcp/test/stdio-server.test.js b/packages/mcp/test/stdio-server.test.js index 76d9534..c7e8ae6 100644 --- a/packages/mcp/test/stdio-server.test.js +++ b/packages/mcp/test/stdio-server.test.js @@ -16,7 +16,7 @@ test('createMicrolinkServer returns MCP server instance', () => { test('createMicrolinkServer sets default instructions and honors overrides', () => { const withDefaults = createMicrolinkServer() - assert.ok(withDefaults.server._instructions.length > 0) + assert.match(withDefaults.server._instructions, /microlink_docs/) const custom = createMicrolinkServer({ instructions: 'Custom.' }) assert.equal(custom.server._instructions, 'Custom.') diff --git a/packages/mcp/test/tools.test.js b/packages/mcp/test/tools.test.js index 6d2e0d4..38f19d6 100644 --- a/packages/mcp/test/tools.test.js +++ b/packages/mcp/test/tools.test.js @@ -13,14 +13,7 @@ import { lighthouse } from '../src/tools/lighthouse.js' import { embed } from '../src/tools/embed.js' import { search } from '../src/tools/search.js' import { fn } from '../src/tools/function.js' -import { text } from '../src/tools/text.js' -import { html } from '../src/tools/html.js' -import { video } from '../src/tools/video.js' -import { images } from '../src/tools/images.js' -import { videos } from '../src/tools/videos.js' -import { audios } from '../src/tools/audios.js' -import { emails } from '../src/tools/emails.js' -import { extract } from '../src/tools/extract.js' +import { tools } from '../src/tools/index.js' // Capture the handler each tool registers so we can invoke it directly, then // stub `fetch` to inspect the request the tool builds via the microlink.io @@ -274,31 +267,11 @@ test('microlink_function sends the function param and returns its value', async test('every tool declares a human-friendly title', () => { const configs = {} - const fakeServer = { + tools({ registerTool: (name, config) => { configs[name] = config } - } - metadata(fakeServer) - logo(fakeServer) - markdown(fakeServer) - screenshot(fakeServer) - pdf(fakeServer) - audio(fakeServer) - links(fakeServer) - technologies(fakeServer) - lighthouse(fakeServer) - embed(fakeServer) - search(fakeServer) - fn(fakeServer) - text(fakeServer) - html(fakeServer) - video(fakeServer) - images(fakeServer) - videos(fakeServer) - audios(fakeServer) - emails(fakeServer) - extract(fakeServer) + }) for (const [name, config] of Object.entries(configs)) { assert.ok( typeof config.title === 'string' && config.title.length > 0,