From 0f5cca7ebf021a9067524af4d6a1538650ca80d6 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 11:36:12 +0200 Subject: [PATCH 1/4] refactor(mcp): describe schemas and gate checkout vs search HOW belongs on the JSON Schema after short tool prose. Checkout is how you get a key, so it registers without MICROLINK_API_KEY; search needs a key, so it registers when one is set. Co-authored-by: Cursor --- packages/mcp/README.md | 2 +- packages/mcp/src/index.js | 36 ++-- packages/mcp/src/schemas.js | 224 ++++++++++++++++++----- packages/mcp/src/tools/index.js | 28 ++- packages/mcp/test/output-schemas.test.js | 13 +- packages/mcp/test/schemas.test.js | 11 ++ packages/mcp/test/stdio-server.test.js | 11 +- packages/mcp/test/tools.test.js | 57 +++++- 8 files changed, 302 insertions(+), 80 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cc304a1..d1cda09 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -142,7 +142,7 @@ 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. `microlink_docs` loads canonical product markdown from microlink.io (the same source as `microlink docs`). +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`). Checkout tools register when `MICROLINK_API_KEY` is unset; `microlink_search` registers when it is set. - `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. diff --git a/packages/mcp/src/index.js b/packages/mcp/src/index.js index 6a17fc9..566b54a 100644 --- a/packages/mcp/src/index.js +++ b/packages/mcp/src/index.js @@ -3,34 +3,46 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' -import { tools } from './tools/index.js' +import { resolveToolset, tools } from './tools/index.js' const require = createRequire(import.meta.url) const { version: pkgVersion } = require('../package.json') -const DEFAULT_INSTRUCTIONS = [ - 'Product tools turn a public URL into a Microlink result. Always pass full URLs including the protocol.', - "Call microlink_docs({ product }) for a product tool's parameters; do not guess nested options.", - 'video/audio return the primary playable asset; videos/audios/links/images collect every match on the page.', - 'extract is custom MQL rules, not full-page markdown/html/text. search takes a query. Checkout tools start and track the purchase of an API key.', - '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(' ') +export function buildInstructions ({ onboarding, search }) { + return [ + 'Product tools turn a public URL into a Microlink result. Always pass full URLs including the protocol.', + "Call microlink_docs({ product }) for a product tool's parameters; do not guess nested options.", + 'video/audio return the primary playable asset; videos/audios/links/images collect every match on the page.', + 'extract is custom MQL rules, not full-page markdown/html/text.', + search ? 'search takes a query, not a URL, and requires an API key.' : null, + onboarding + ? 'Checkout tools buy an API key. Give checkoutUrl to the human; never open it or expect the API secret in the tool result.' + : null, + '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.' + ] + .filter(Boolean) + .join(' ') +} export function createMicrolinkServer ({ name = 'microlink-mcp-server', version = pkgVersion, - instructions = DEFAULT_INSTRUCTIONS + instructions, + apiKey, + onboarding, + search } = {}) { + const toolset = resolveToolset({ apiKey, onboarding, search }) const server = new McpServer( { name, version }, - { instructions } + { instructions: instructions ?? buildInstructions(toolset) } ) - tools(server) + tools(server, toolset) return server } diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index 39229a7..75fd9e4 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -104,8 +104,15 @@ const viewportSchema = objectLikeSchema( const screenshotOverlaySchema = objectLikeSchema( z .object({ - browser: z.enum(['light', 'dark']).optional(), - background: z.string().min(1).optional() + browser: z + .enum(['light', 'dark']) + .optional() + .describe('Browser chrome theme for the overlay.'), + background: z + .string() + .min(1) + .optional() + .describe('Overlay background color (CSS color).') }) .strict() ) @@ -113,16 +120,48 @@ const screenshotOverlaySchema = objectLikeSchema( export const screenshotConfigSchema = objectLikeSchema( z .object({ - animated: booleanSchema.optional(), - codeScheme: z.string().min(1).optional(), - element: z.string().min(1).optional(), - fullPage: booleanSchema.optional(), - omitBackground: booleanSchema.optional(), - optimizeForSpeed: booleanSchema.optional(), - overlay: screenshotOverlaySchema.optional(), - palette: booleanSchema.optional(), - quality: z.number().min(0).max(100).optional(), - type: z.enum(['jpeg', 'png']).optional() + animated: booleanSchema + .optional() + .describe( + 'Capture an animated screenshot (GIF/MP4) instead of a still.' + ), + codeScheme: z + .string() + .min(1) + .optional() + .describe( + 'Syntax-highlight theme for code pages (for example dracula).' + ), + element: z + .string() + .min(1) + .optional() + .describe('CSS selector of the element to capture.'), + fullPage: booleanSchema + .optional() + .describe('Capture the full scrollable page.'), + omitBackground: booleanSchema + .optional() + .describe('Transparent background (png).'), + optimizeForSpeed: booleanSchema + .optional() + .describe('Faster encode, larger file.'), + overlay: screenshotOverlaySchema + .optional() + .describe('Browser chrome overlay.'), + palette: booleanSchema + .optional() + .describe('Also extract dominant colors.'), + quality: z + .number() + .min(0) + .max(100) + .optional() + .describe('JPEG quality (0–100).'), + type: z + .enum(['jpeg', 'png']) + .optional() + .describe('Image format. Default png.') }) .strict() ) @@ -158,13 +197,24 @@ export const pdfConfigSchema = objectLikeSchema( 'A5', 'A6' ]) - .optional(), - height: z.string().min(1).optional(), - landscape: booleanSchema.optional(), - margin: pdfMarginSchema.optional(), - pageRanges: z.string().min(1).optional(), - scale: z.number().min(0.1).max(2).optional(), - width: z.string().min(1).optional() + .optional() + .describe('Page format. Default A4.'), + height: z + .string() + .min(1) + .optional() + .describe('Page height (CSS length).'), + landscape: booleanSchema.optional().describe('Landscape orientation.'), + margin: pdfMarginSchema + .optional() + .describe('Margin as a CSS length or { top, bottom, left, right }.'), + pageRanges: z + .string() + .min(1) + .optional() + .describe('Pages to print, for example 1-3.'), + scale: z.number().min(0.1).max(2).optional().describe('Scale (0.1–2).'), + width: z.string().min(1).optional().describe('Page width (CSS length).') }) .strict() ) @@ -254,22 +304,33 @@ const browserSchema = { .min(1) .optional() .describe(`Custom cache key for the request. ${PRO}`), - click: stringOrStringArraySchema.optional(), - colorScheme: z.enum(['no-preference', 'light', 'dark']).optional(), - device: z.string().min(1).optional(), + click: stringOrStringArraySchema + .optional() + .describe('CSS selector(s) to click before capture.'), + colorScheme: z + .enum(['no-preference', 'light', 'dark']) + .optional() + .describe('Preferred color scheme.'), + device: z + .string() + .min(1) + .optional() + .describe('Emulate a device, for example iPhone 12.'), filename: z .string() .min(1) .optional() .describe(`Custom name for the generated asset. ${PRO}`), filter: z.string().min(1).optional(), - force: booleanSchema.optional(), + force: booleanSchema.optional().describe('Bypass cache.'), headers: objectLikeSchema( z.record(z.string(), z.union([z.string(), z.number(), booleanSchema])) ) .optional() .describe(`Custom HTTP headers sent to the target URL. ${PRO}`), - javascript: booleanSchema.optional(), + javascript: booleanSchema + .optional() + .describe('Toggle JavaScript execution on the target page.'), mediaType: z.enum(['screen', 'print']).optional(), modules: stringOrStringArraySchema.optional(), prerender: z.union([z.literal('auto'), booleanSchema]).optional(), @@ -280,7 +341,11 @@ const browserSchema = { ), retry: z.number().int().nonnegative().optional(), scripts: stringOrStringArraySchema.optional(), - scroll: z.string().min(1).optional(), + scroll: z + .string() + .min(1) + .optional() + .describe('CSS selector to scroll into view before capture.'), staleTtl: z .union([z.string(), z.number(), booleanSchema]) .optional() @@ -291,16 +356,25 @@ const browserSchema = { timeout: stringOrNumberSchema.optional(), ttl: stringOrNumberSchema.optional(), viewport: viewportSchema.optional(), - waitForSelector: z.string().min(1).optional(), + waitForSelector: z + .string() + .min(1) + .optional() + .describe('Wait for this CSS selector before capture.'), waitForTimeout: stringOrNumberSchema.optional(), waitUntil: z .union([waitUntilEventSchema, z.array(waitUntilEventSchema).min(1)]) .optional() + .describe( + 'Navigation event to wait for: auto, load, domcontentloaded, networkidle0, networkidle2.' + ) } const visualSchema = { ...browserSchema, - data: objectLikeSchema(z.record(z.string(), dataRuleSchema)).optional() + data: objectLikeSchema(z.record(z.string(), dataRuleSchema)) + .optional() + .describe('Custom MQL data rules (selector, attr, type).') } export const extractInputSchema = baseSchema @@ -318,14 +392,22 @@ export const extractInputSchema = baseSchema export const screenshotInputSchema = baseSchema .extend(visualSchema) .extend({ - screenshot: z.union([booleanSchema, screenshotConfigSchema]).optional() + screenshot: z + .union([booleanSchema, screenshotConfigSchema]) + .optional() + .describe( + 'true for defaults, or an object (fullPage, element, type, overlay, animated).' + ) }) .strict() export const pdfInputSchema = baseSchema .extend(visualSchema) .extend({ - pdf: z.union([booleanSchema, pdfConfigSchema]).optional() + pdf: z + .union([booleanSchema, pdfConfigSchema]) + .optional() + .describe('true for defaults, or an object (format, margin, landscape).') }) .strict() @@ -348,7 +430,9 @@ export const videoInputSchema = baseSchema export const logoInputSchema = baseSchema .extend(visualSchema) .extend({ - square: booleanSchema.optional(), + square: booleanSchema + .optional() + .describe('Prefer the square (icon-shaped) logo variant.'), palette: booleanSchema.optional() }) .strict() @@ -356,7 +440,12 @@ export const logoInputSchema = baseSchema export const metadataInputSchema = baseSchema .extend(visualSchema) .extend({ - meta: z.union([booleanSchema, metaConfigSchema]).optional(), + meta: z + .union([booleanSchema, metaConfigSchema]) + .optional() + .describe( + 'true to include metadata, false to skip, or an object to include/exclude fields.' + ), palette: booleanSchema.optional() }) .strict() @@ -365,7 +454,9 @@ export const metadataInputSchema = baseSchema const contentSchema = baseSchema .extend(browserSchema) .extend({ - selector: selectorSchema.optional(), + selector: selectorSchema + .optional() + .describe('CSS selector to scope the extracted content.'), selectorAll: selectorSchema.optional(), type: z.string().min(1).optional() }) @@ -376,8 +467,14 @@ const collectionSchema = baseSchema .extend(browserSchema) .extend({ selector: selectorSchema.optional(), - selectorAll: selectorSchema.optional(), - attr: z.string().min(1).optional(), + selectorAll: selectorSchema + .optional() + .describe('CSS selector(s) matching many nodes.'), + attr: z + .string() + .min(1) + .optional() + .describe('Attribute to read (href, src, ...).'), type: z.string().min(1).optional() }) .strict() @@ -391,8 +488,18 @@ export const textInputSchema = contentSchema export const embedInputSchema = baseSchema .extend(visualSchema) .extend({ - maxWidth: z.coerce.number().int().positive().optional(), - maxHeight: z.coerce.number().int().positive().optional() + maxWidth: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Maximum iframe width in pixels.'), + maxHeight: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Maximum iframe height in pixels.') }) .strict() @@ -411,10 +518,16 @@ export const technologiesInputSchema = baseSchema.extend(visualSchema).strict() export const lighthouseInputSchema = baseSchema .extend(visualSchema) .extend({ - onlyCategories: z.array(z.string().min(1)).optional(), + onlyCategories: z + .array(z.string().min(1)) + .optional() + .describe('Limit the report to these Lighthouse categories.'), onlyAudits: z.array(z.string().min(1)).optional(), skipAudits: z.array(z.string().min(1)).optional(), - output: z.union([z.string().min(1), z.array(z.string().min(1))]).optional() + output: z + .union([z.string().min(1), z.array(z.string().min(1))]) + .optional() + .describe('Report format: json, html, or csv.') }) .strict() @@ -442,18 +555,41 @@ export const searchInputSchema = z 'patents', 'autocomplete' ]) - .optional(), - limit: z.coerce.number().int().positive().optional(), - page: z.coerce.number().int().positive().optional(), - location: z.string().min(1).optional(), - period: z.enum(['hour', 'day', 'week', 'month', 'year']).optional() + .optional() + .describe('Search vertical. Default search.'), + limit: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Maximum number of results.'), + page: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Results page. Default 1.'), + location: z + .string() + .min(1) + .optional() + .describe('Country or locale, for example es.'), + period: z + .enum(['hour', 'day', 'week', 'month', 'year']) + .optional() + .describe('Recency filter.') }) .strict() export const functionInputSchema = baseSchema .extend(visualSchema) .extend({ - code: z.string().min(1) + code: z + .string() + .min(1) + .describe( + 'Function source, for example "async ({ page }) => page.title()".' + ) }) .strict() diff --git a/packages/mcp/src/tools/index.js b/packages/mcp/src/tools/index.js index 0910472..60e9746 100644 --- a/packages/mcp/src/tools/index.js +++ b/packages/mcp/src/tools/index.js @@ -23,10 +23,28 @@ import { text } from './text.js' import { video } from './video.js' import { videos } from './videos.js' -export function tools (server) { - plans(server) - checkoutCreate(server) - checkoutStatus(server) +// Search needs a key. Checkout is how you get one. Default: show checkout +// without MICROLINK_API_KEY, show search when the key is set. +export function resolveToolset ({ + apiKey = process.env.MICROLINK_API_KEY, + onboarding, + search: includeSearch +} = {}) { + const hasKey = Boolean(apiKey) + return { + onboarding: onboarding ?? !hasKey, + search: includeSearch ?? hasKey + } +} + +export function tools (server, options) { + const { onboarding, search: includeSearch } = resolveToolset(options) + + if (onboarding) { + plans(server) + checkoutCreate(server) + checkoutStatus(server) + } docs(server) metadata(server) logo(server) @@ -45,7 +63,7 @@ export function tools (server) { emails(server) technologies(server) lighthouse(server) - search(server) + if (includeSearch) search(server) fn(server) extract(server) } diff --git a/packages/mcp/test/output-schemas.test.js b/packages/mcp/test/output-schemas.test.js index 7fa4ab7..7ab54cb 100644 --- a/packages/mcp/test/output-schemas.test.js +++ b/packages/mcp/test/output-schemas.test.js @@ -8,11 +8,14 @@ import { outputSchemas } from '../src/output-schemas.js' function captureAll () { const registered = {} - tools({ - registerTool: (name, config, handler) => { - registered[name] = { config, handler } - } - }) + tools( + { + registerTool: (name, config, handler) => { + registered[name] = { config, handler } + } + }, + { onboarding: true, search: true } + ) return registered } diff --git a/packages/mcp/test/schemas.test.js b/packages/mcp/test/schemas.test.js index 9cbb3f4..119a690 100644 --- a/packages/mcp/test/schemas.test.js +++ b/packages/mcp/test/schemas.test.js @@ -601,6 +601,17 @@ test('text schema rejects unknown top-level keys', () => { ) }) +test('screenshot schema documents fullPage for MCP clients', () => { + const { properties } = z.toJSONSchema(screenshotInputSchema) + const screenshot = properties.screenshot + const fullPage = + screenshot.anyOf?.find(entry => entry.properties?.fullPage)?.properties + ?.fullPage ?? screenshot.properties?.fullPage + + assert.match(screenshot.description, /fullPage/) + assert.match(fullPage.description, /full scrollable page/) +}) + test('PRO parameters are labeled in the JSON Schema shown to MCP clients', () => { const { properties } = z.toJSONSchema(screenshotInputSchema) diff --git a/packages/mcp/test/stdio-server.test.js b/packages/mcp/test/stdio-server.test.js index cc055ba..e1db901 100644 --- a/packages/mcp/test/stdio-server.test.js +++ b/packages/mcp/test/stdio-server.test.js @@ -15,9 +15,14 @@ test('createMicrolinkServer returns MCP server instance', () => { }) test('createMicrolinkServer sets default instructions and honors overrides', () => { - const withDefaults = createMicrolinkServer() - assert.match(withDefaults.server._instructions, /microlink_docs/) - assert.match(withDefaults.server._instructions, /Checkout tools/) + const withoutKey = createMicrolinkServer({ apiKey: '' }) + assert.match(withoutKey.server._instructions, /microlink_docs/) + assert.match(withoutKey.server._instructions, /Checkout tools/) + assert.doesNotMatch(withoutKey.server._instructions, /search takes a query/) + + const withKey = createMicrolinkServer({ apiKey: 'test-key' }) + assert.match(withKey.server._instructions, /search takes a query/) + assert.doesNotMatch(withKey.server._instructions, /Checkout tools/) 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 c991374..1a1e3a7 100644 --- a/packages/mcp/test/tools.test.js +++ b/packages/mcp/test/tools.test.js @@ -267,11 +267,14 @@ test('microlink_function sends the function param and returns its value', async test('every tool declares a human-friendly title', () => { const configs = {} - tools({ - registerTool: (name, config) => { - configs[name] = config - } - }) + tools( + { + registerTool: (name, config) => { + configs[name] = config + } + }, + { onboarding: true, search: true } + ) for (const [name, config] of Object.entries(configs)) { assert.ok( typeof config.title === 'string' && config.title.length > 0, @@ -282,11 +285,14 @@ test('every tool declares a human-friendly title', () => { test('product tool descriptions name the job, not every parameter', () => { const configs = {} - tools({ - registerTool: (name, config) => { - configs[name] = config - } - }) + tools( + { + registerTool: (name, config) => { + configs[name] = config + } + }, + { onboarding: true, search: true } + ) assert.doesNotMatch(configs.microlink_screenshot.description, /fullPage/) assert.doesNotMatch(configs.microlink_pdf.description, /pageRanges/) @@ -296,6 +302,37 @@ test('product tool descriptions name the job, not every parameter', () => { assert.match(configs.microlink_markdown.description, /microlink_extract/) }) +test('without an API key, checkout is listed and search is not', () => { + const names = [] + tools( + { + registerTool: name => { + names.push(name) + } + }, + { apiKey: '' } + ) + assert.ok(names.includes('microlink_list_plans')) + assert.ok(names.includes('microlink_create_checkout_session')) + assert.equal(names.includes('microlink_search'), false) +}) + +test('with an API key, search is listed and checkout is not', () => { + const names = [] + tools( + { + registerTool: name => { + names.push(name) + } + }, + { apiKey: 'test-key' } + ) + assert.ok(names.includes('microlink_search')) + assert.equal(names.includes('microlink_list_plans'), false) + assert.equal(names.includes('microlink_create_checkout_session'), false) + assert.ok(names.includes('microlink_screenshot')) +}) + test('errors are surfaced as MCP isError with code/message', async t => { const handlers = captureTool(metadata) await withStubbedRequest( From c3f6dcfb6d1ef439cc3b31ec5c57877029a07ba4 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 11:39:48 +0200 Subject: [PATCH 2/4] refactor(mcp): always list search and checkout Hiding search when no API key kills the buy funnel: the model never picks the tool, never hits the missing-key error, never suggests checkout. Co-authored-by: Cursor --- packages/mcp/README.md | 2 +- packages/mcp/src/index.js | 36 +++++---------- packages/mcp/src/tools/index.js | 28 +++--------- packages/mcp/src/tools/search.js | 2 +- packages/mcp/test/output-schemas.test.js | 13 +++--- packages/mcp/test/stdio-server.test.js | 12 ++--- packages/mcp/test/tools.test.js | 57 +++++------------------- 7 files changed, 38 insertions(+), 112 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d1cda09..cc304a1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -142,7 +142,7 @@ 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. `microlink_docs` loads canonical product markdown from microlink.io (the same source as `microlink docs`). Checkout tools register when `MICROLINK_API_KEY` is unset; `microlink_search` registers when it is set. +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. diff --git a/packages/mcp/src/index.js b/packages/mcp/src/index.js index 566b54a..f3cd964 100644 --- a/packages/mcp/src/index.js +++ b/packages/mcp/src/index.js @@ -3,46 +3,34 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' -import { resolveToolset, tools } from './tools/index.js' +import { tools } from './tools/index.js' const require = createRequire(import.meta.url) const { version: pkgVersion } = require('../package.json') -export function buildInstructions ({ onboarding, search }) { - return [ - 'Product tools turn a public URL into a Microlink result. Always pass full URLs including the protocol.', - "Call microlink_docs({ product }) for a product tool's parameters; do not guess nested options.", - 'video/audio return the primary playable asset; videos/audios/links/images collect every match on the page.', - 'extract is custom MQL rules, not full-page markdown/html/text.', - search ? 'search takes a query, not a URL, and requires an API key.' : null, - onboarding - ? 'Checkout tools buy an API key. Give checkoutUrl to the human; never open it or expect the API secret in the tool result.' - : null, - '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.' - ] - .filter(Boolean) - .join(' ') -} +const DEFAULT_INSTRUCTIONS = [ + 'Product tools turn a public URL into a Microlink result. Always pass full URLs including the protocol.', + "Call microlink_docs({ product }) for a product tool's parameters; do not guess nested options.", + 'video/audio return the primary playable asset; videos/audios/links/images collect every match on the page.', + 'extract is custom MQL rules, not full-page markdown/html/text. search takes a query and requires an API key; if the key is missing, use checkout tools so the human can buy one.', + '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(' ') export function createMicrolinkServer ({ name = 'microlink-mcp-server', version = pkgVersion, - instructions, - apiKey, - onboarding, - search + instructions = DEFAULT_INSTRUCTIONS } = {}) { - const toolset = resolveToolset({ apiKey, onboarding, search }) const server = new McpServer( { name, version }, - { instructions: instructions ?? buildInstructions(toolset) } + { instructions } ) - tools(server, toolset) + tools(server) return server } diff --git a/packages/mcp/src/tools/index.js b/packages/mcp/src/tools/index.js index 60e9746..0910472 100644 --- a/packages/mcp/src/tools/index.js +++ b/packages/mcp/src/tools/index.js @@ -23,28 +23,10 @@ import { text } from './text.js' import { video } from './video.js' import { videos } from './videos.js' -// Search needs a key. Checkout is how you get one. Default: show checkout -// without MICROLINK_API_KEY, show search when the key is set. -export function resolveToolset ({ - apiKey = process.env.MICROLINK_API_KEY, - onboarding, - search: includeSearch -} = {}) { - const hasKey = Boolean(apiKey) - return { - onboarding: onboarding ?? !hasKey, - search: includeSearch ?? hasKey - } -} - -export function tools (server, options) { - const { onboarding, search: includeSearch } = resolveToolset(options) - - if (onboarding) { - plans(server) - checkoutCreate(server) - checkoutStatus(server) - } +export function tools (server) { + plans(server) + checkoutCreate(server) + checkoutStatus(server) docs(server) metadata(server) logo(server) @@ -63,7 +45,7 @@ export function tools (server, options) { emails(server) technologies(server) lighthouse(server) - if (includeSearch) search(server) + search(server) fn(server) extract(server) } diff --git a/packages/mcp/src/tools/search.js b/packages/mcp/src/tools/search.js index d56d595..0f0b7ff 100644 --- a/packages/mcp/src/tools/search.js +++ b/packages/mcp/src/tools/search.js @@ -5,7 +5,7 @@ export function search (server) { register( server, 'microlink_search', - 'Google as structured data. Takes `query` (not a URL). Requires an API key.', + 'Google as structured data. Takes `query` (not a URL). Requires an API key; if none, use checkout tools so the human can buy one.', searchInputSchema, (client, { query, ...options }) => client.search(query, options) ) diff --git a/packages/mcp/test/output-schemas.test.js b/packages/mcp/test/output-schemas.test.js index 7ab54cb..7fa4ab7 100644 --- a/packages/mcp/test/output-schemas.test.js +++ b/packages/mcp/test/output-schemas.test.js @@ -8,14 +8,11 @@ import { outputSchemas } from '../src/output-schemas.js' function captureAll () { const registered = {} - tools( - { - registerTool: (name, config, handler) => { - registered[name] = { config, handler } - } - }, - { onboarding: true, search: true } - ) + tools({ + registerTool: (name, config, handler) => { + registered[name] = { config, handler } + } + }) return registered } diff --git a/packages/mcp/test/stdio-server.test.js b/packages/mcp/test/stdio-server.test.js index e1db901..185c0cb 100644 --- a/packages/mcp/test/stdio-server.test.js +++ b/packages/mcp/test/stdio-server.test.js @@ -15,14 +15,10 @@ test('createMicrolinkServer returns MCP server instance', () => { }) test('createMicrolinkServer sets default instructions and honors overrides', () => { - const withoutKey = createMicrolinkServer({ apiKey: '' }) - assert.match(withoutKey.server._instructions, /microlink_docs/) - assert.match(withoutKey.server._instructions, /Checkout tools/) - assert.doesNotMatch(withoutKey.server._instructions, /search takes a query/) - - const withKey = createMicrolinkServer({ apiKey: 'test-key' }) - assert.match(withKey.server._instructions, /search takes a query/) - assert.doesNotMatch(withKey.server._instructions, /Checkout tools/) + const withDefaults = createMicrolinkServer() + assert.match(withDefaults.server._instructions, /microlink_docs/) + assert.match(withDefaults.server._instructions, /requires an API key/) + assert.match(withDefaults.server._instructions, /checkout tools/) 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 1a1e3a7..c991374 100644 --- a/packages/mcp/test/tools.test.js +++ b/packages/mcp/test/tools.test.js @@ -267,14 +267,11 @@ test('microlink_function sends the function param and returns its value', async test('every tool declares a human-friendly title', () => { const configs = {} - tools( - { - registerTool: (name, config) => { - configs[name] = config - } - }, - { onboarding: true, search: true } - ) + tools({ + registerTool: (name, config) => { + configs[name] = config + } + }) for (const [name, config] of Object.entries(configs)) { assert.ok( typeof config.title === 'string' && config.title.length > 0, @@ -285,14 +282,11 @@ test('every tool declares a human-friendly title', () => { test('product tool descriptions name the job, not every parameter', () => { const configs = {} - tools( - { - registerTool: (name, config) => { - configs[name] = config - } - }, - { onboarding: true, search: true } - ) + tools({ + registerTool: (name, config) => { + configs[name] = config + } + }) assert.doesNotMatch(configs.microlink_screenshot.description, /fullPage/) assert.doesNotMatch(configs.microlink_pdf.description, /pageRanges/) @@ -302,37 +296,6 @@ test('product tool descriptions name the job, not every parameter', () => { assert.match(configs.microlink_markdown.description, /microlink_extract/) }) -test('without an API key, checkout is listed and search is not', () => { - const names = [] - tools( - { - registerTool: name => { - names.push(name) - } - }, - { apiKey: '' } - ) - assert.ok(names.includes('microlink_list_plans')) - assert.ok(names.includes('microlink_create_checkout_session')) - assert.equal(names.includes('microlink_search'), false) -}) - -test('with an API key, search is listed and checkout is not', () => { - const names = [] - tools( - { - registerTool: name => { - names.push(name) - } - }, - { apiKey: 'test-key' } - ) - assert.ok(names.includes('microlink_search')) - assert.equal(names.includes('microlink_list_plans'), false) - assert.equal(names.includes('microlink_create_checkout_session'), false) - assert.ok(names.includes('microlink_screenshot')) -}) - test('errors are surfaced as MCP isError with code/message', async t => { const handlers = captureTool(metadata) await withStubbedRequest( From e1c4188185690d89b7a847fb4104dafded062ccf Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 15:33:48 +0200 Subject: [PATCH 3/4] fix: correct animated output formats --- packages/core/bin/help.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) mode change 100644 => 100755 packages/core/bin/help.js diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js old mode 100644 new mode 100755 index 9d59e2e..133383e --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -197,7 +197,7 @@ const PRODUCTS = { ['--optimizeForSpeed', 'Faster encode, larger file'], ['--overlay', 'Browser chrome overlay as JSON'], ['--codeScheme', 'Syntax theme for code pages'], - ['--animated', 'Animated screenshot (GIF/MP4)'], + ['--animated', 'Capture a short video (MP4/WebM) instead of a still'], ['--palette', 'Also extract dominant colors'], ['--quality', 'JPEG quality (0–100)'] ], @@ -389,3 +389,4 @@ module.exports = command => { if (COMMANDS[command]) return render(command, COMMANDS[command]) return PRODUCTS[command] ? render(command, PRODUCTS[command]) : global } + From 837e71bf87a2cef0d65c877ecaa7d48339bee611 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 15:36:28 +0200 Subject: [PATCH 4/4] fix: linter --- packages/core/bin/help.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js index 133383e..cd10479 100755 --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -389,4 +389,3 @@ module.exports = command => { if (COMMANDS[command]) return render(command, COMMANDS[command]) return PRODUCTS[command] ? render(command, PRODUCTS[command]) : global } -