Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/core/bin/docs.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -9,4 +32,4 @@ const load = async (product, fetchFn = fetch) => {
return res.text()
}

module.exports = { load, url }
module.exports = { load, url, products }
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion packages/core/test/docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
24 changes: 20 additions & 4 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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 <product> 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.
Expand All @@ -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`.

Expand All @@ -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 <product> docs` (`https://microlink.io/docs/sdk/methods/<product>.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.
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ')
Expand Down
10 changes: 6 additions & 4 deletions packages/mcp/src/output-schemas.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -124,6 +125,7 @@ export const outputSchemas = {
list_plans: plansSchema,
create_checkout_session: checkoutSessionSchema,
get_checkout_session: checkoutStatusSchema,
docs: z.string(),
metadata: metadataSchema,
logo: nullableAssetSchema,
markdown: stringSchema,
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp/src/schemas.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { products as DOC_PRODUCTS } from 'microlink.io/docs'
import { z } from 'zod'

const stringOrStringArraySchema = z.union([
Expand Down Expand Up @@ -456,6 +457,16 @@ export const functionInputSchema = baseSchema
})
.strict()

export const docsInputSchema = z
.object({
product: z
.enum(DOC_PRODUCTS, {
error: `Unknown product. Valid products: ${DOC_PRODUCTS.join(', ')}.`
})
.describe('Microlink product whose canonical parameter docs to fetch.')
})
.strict()

export const listPlansInputSchema = z.object({}).strict()

export const createCheckoutSessionInputSchema = z
Expand Down
18 changes: 18 additions & 0 deletions packages/mcp/src/tools/docs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { load as loadProductDocs } from 'microlink.io/docs'

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 <product> docs`.'
].join(' '),
docsInputSchema,
(_client, { product }) => loadProductDocs(product)
)
}
2 changes: 2 additions & 0 deletions packages/mcp/src/tools/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,6 +27,7 @@ export function tools (server) {
plans(server)
checkoutCreate(server)
checkoutStatus(server)
docs(server)
metadata(server)
logo(server)
markdown(server)
Expand Down
8 changes: 5 additions & 3 deletions packages/mcp/src/tools/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -59,6 +60,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',
Expand Down
72 changes: 72 additions & 0 deletions packages/mcp/test/docs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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')
})

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/)
})
2 changes: 1 addition & 1 deletion packages/mcp/test/stdio-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
33 changes: 3 additions & 30 deletions packages/mcp/test/tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down