diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 0efb094..b821483 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -17,7 +17,8 @@ const SCREENSHOT_KEYS = [ 'optimizeForSpeed', 'codeScheme', 'animated', - 'palette' + 'palette', + 'quality' ] const PDF_KEYS = [ diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index 5fff4a2..06d749f 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -120,6 +120,7 @@ export const screenshotConfigSchema = objectLikeSchema( optimizeForSpeed: booleanSchema.optional(), overlay: screenshotOverlaySchema.optional(), palette: booleanSchema.optional(), + quality: z.number().min(0).max(100).optional(), type: z.enum(['jpeg', 'png']).optional() }) .strict() @@ -227,12 +228,15 @@ const fullShape = { ping: toggledObjectSchema.optional() } -const visualSchema = { +// Shared Microlink API query parameters (see microlink.io/docs/api/parameters). +// Product tools layer their own fields on top; these apply to any URL fetch. +// `data` is separate: content/collection helpers overwrite it with their field rule. +const browserSchema = { adblock: booleanSchema.optional(), animations: booleanSchema.optional(), + cacheKey: z.string().min(1).optional(), click: stringOrStringArraySchema.optional(), colorScheme: z.enum(['no-preference', 'light', 'dark']).optional(), - data: objectLikeSchema(z.record(z.string(), dataRuleSchema)).optional(), device: z.string().min(1).optional(), filename: z.string().min(1).optional(), filter: z.string().min(1).optional(), @@ -260,6 +264,11 @@ const visualSchema = { .optional() } +const visualSchema = { + ...browserSchema, + data: objectLikeSchema(z.record(z.string(), dataRuleSchema)).optional() +} + export const extractInputSchema = baseSchema .extend(fullShape) .extend(visualSchema) @@ -287,35 +296,40 @@ export const pdfInputSchema = baseSchema .strict() export const audioInputSchema = baseSchema + .extend(visualSchema) .extend({ - proxy: proxySchema.optional(), meta: z.union([booleanSchema, metaConfigSchema]).optional(), audio: booleanSchema.optional() }) .strict() export const videoInputSchema = baseSchema + .extend(visualSchema) .extend({ - proxy: proxySchema.optional(), meta: z.union([booleanSchema, metaConfigSchema]).optional(), video: booleanSchema.optional() }) .strict() export const logoInputSchema = baseSchema + .extend(visualSchema) .extend({ - square: booleanSchema.optional() + square: booleanSchema.optional(), + palette: booleanSchema.optional() }) .strict() export const metadataInputSchema = baseSchema + .extend(visualSchema) .extend({ - meta: z.union([booleanSchema, metaConfigSchema]).optional() + meta: z.union([booleanSchema, metaConfigSchema]).optional(), + palette: booleanSchema.optional() }) .strict() // Content tools (markdown/html/text) accept a selector to scope the extraction. const contentSchema = baseSchema + .extend(browserSchema) .extend({ selector: selectorSchema.optional(), selectorAll: selectorSchema.optional(), @@ -325,6 +339,7 @@ const contentSchema = baseSchema // Collection tools accept selector/attr/type overrides for the data rule. const collectionSchema = baseSchema + .extend(browserSchema) .extend({ selector: selectorSchema.optional(), selectorAll: selectorSchema.optional(), @@ -340,6 +355,7 @@ export const htmlInputSchema = contentSchema 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() @@ -356,9 +372,10 @@ export const audiosInputSchema = collectionSchema export const emailsInputSchema = collectionSchema -export const technologiesInputSchema = baseSchema.strict() +export const technologiesInputSchema = baseSchema.extend(visualSchema).strict() export const lighthouseInputSchema = baseSchema + .extend(visualSchema) .extend({ onlyCategories: z.array(z.string().min(1)).optional(), onlyAudits: z.array(z.string().min(1)).optional(), @@ -392,6 +409,7 @@ export const searchInputSchema = z .strict() export const functionInputSchema = baseSchema + .extend(visualSchema) .extend({ code: z.string().min(1) }) diff --git a/packages/mcp/src/tools/function.js b/packages/mcp/src/tools/function.js index 1c457cc..ecbe53c 100644 --- a/packages/mcp/src/tools/function.js +++ b/packages/mcp/src/tools/function.js @@ -8,6 +8,7 @@ export function fn (server) { [ 'Run a JavaScript function against any public URL inside Microlink’s server-side browser sandbox.', 'Pass `code` as the function source (e.g. "async ({ page }) => page.title()"); it receives `{ page, response, ...args }` and its return value comes back in `value`.', + 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `click`, `scroll`, `headers`, and `proxy`.', 'Also returns `isFulfilled`, `profiling`, and `logging`. Mirrors the `microlink.function(url, code)` library method.' ].join(' '), functionInputSchema, diff --git a/packages/mcp/src/tools/html.js b/packages/mcp/src/tools/html.js index c345252..55f1d8b 100644 --- a/packages/mcp/src/tools/html.js +++ b/packages/mcp/src/tools/html.js @@ -8,6 +8,7 @@ export function html (server) { [ 'Extract the HTML content of any public URL via Microlink.', 'Returns the page HTML as a string. Pass `selector` to scope it to part of the page.', + 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `headers`, and `proxy`.', 'Mirrors the `microlink.html(url)` library method.' ].join(' '), htmlInputSchema, diff --git a/packages/mcp/src/tools/markdown.js b/packages/mcp/src/tools/markdown.js index 197ef06..fb0924a 100644 --- a/packages/mcp/src/tools/markdown.js +++ b/packages/mcp/src/tools/markdown.js @@ -8,6 +8,7 @@ export function markdown (server) { [ 'Convert any public URL to Markdown via Microlink.', 'Returns the page content as a Markdown string. Pass `selector` to scope it to part of the page.', + 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `headers`, and `proxy`.', 'Mirrors the `microlink.markdown(url)` library method.' ].join(' '), markdownInputSchema, diff --git a/packages/mcp/src/tools/text.js b/packages/mcp/src/tools/text.js index 4551276..749f3ef 100644 --- a/packages/mcp/src/tools/text.js +++ b/packages/mcp/src/tools/text.js @@ -8,6 +8,7 @@ export function text (server) { [ 'Extract the plain text of any public URL via Microlink.', 'Returns the readable page text as a string. Pass `selector` to scope it to part of the page.', + 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `headers`, and `proxy`.', 'Mirrors the `microlink.text(url)` library method.' ].join(' '), textInputSchema, diff --git a/packages/mcp/test/schemas.test.js b/packages/mcp/test/schemas.test.js index 53b0576..2d81415 100644 --- a/packages/mcp/test/schemas.test.js +++ b/packages/mcp/test/schemas.test.js @@ -4,6 +4,7 @@ import assert from 'node:assert/strict' import { audioInputSchema, extractInputSchema, + functionInputSchema, markdownInputSchema, metadataInputSchema, logoInputSchema, @@ -66,6 +67,30 @@ test('screenshot schema accepts boolean toggle', () => { assert.equal(result.success, true) }) +test('screenshot schema accepts jpeg quality', () => { + const result = screenshotInputSchema.safeParse({ + url: 'https://microlink.io', + screenshot: { + type: 'jpeg', + quality: 50 + } + }) + + assert.equal(result.success, true) + assert.equal(result.data.screenshot.quality, 50) +}) + +test('screenshot schema rejects quality outside 0-100', () => { + const result = screenshotInputSchema.safeParse({ + url: 'https://microlink.io', + screenshot: { + quality: 101 + } + }) + + assert.equal(result.success, false) +}) + test('screenshot schema coerces string booleans for toggle and nested options', () => { const result = screenshotInputSchema.safeParse({ url: 'https://microlink.io', @@ -479,6 +504,33 @@ test('markdown schema accepts optional apiKey', () => { assert.equal(result.success, true) }) +test('markdown schema accepts shared browser query parameters', () => { + const result = markdownInputSchema.safeParse({ + url: 'https://microlink.io', + javascript: true, + waitUntil: 'networkidle0', + waitForSelector: 'main', + cacheKey: 'docs-markdown' + }) + + assert.equal(result.success, true) + assert.equal(result.data.waitUntil, 'networkidle0') + assert.equal(result.data.cacheKey, 'docs-markdown') +}) + +test('markdown schema rejects top-level data (content helpers overwrite it)', () => { + const result = markdownInputSchema.safeParse({ + url: 'https://microlink.io', + data: { custom: { selector: 'h1' } } + }) + + assert.equal(result.success, false) + assert.match( + result.error.issues[0].message, + /Unrecognized key|unrecognized key/i + ) +}) + test('markdown schema rejects unknown top-level keys', () => { const result = markdownInputSchema.safeParse({ url: 'https://microlink.io', @@ -492,6 +544,30 @@ test('markdown schema rejects unknown top-level keys', () => { ) }) +test('function schema accepts code with shared browser options', () => { + const result = functionInputSchema.safeParse({ + url: 'https://microlink.io', + code: 'async ({ page }) => page.title()', + waitUntil: 'domcontentloaded', + click: '#accept' + }) + + assert.equal(result.success, true) + assert.equal(result.data.waitUntil, 'domcontentloaded') + assert.equal(result.data.click, '#accept') +}) + +test('metadata schema accepts palette and waitUntil', () => { + const result = metadataInputSchema.safeParse({ + url: 'https://microlink.io', + palette: true, + waitUntil: 'load' + }) + + assert.equal(result.success, true) + assert.equal(result.data.palette, true) +}) + test('text schema accepts minimal valid payload', () => { const result = textInputSchema.safeParse({ url: 'https://microlink.io' diff --git a/packages/mcp/test/tools.test.js b/packages/mcp/test/tools.test.js index 8bcd57a..8f8eb47 100644 --- a/packages/mcp/test/tools.test.js +++ b/packages/mcp/test/tools.test.js @@ -104,6 +104,27 @@ test('microlink_markdown scopes to `selector` and returns a string', async t => ) }) +test('microlink_markdown forwards waitUntil and javascript', async t => { + const handlers = captureTool(markdown) + await withStubbedRequest( + t, + async getUrl => { + await handlers.microlink_markdown( + { + url: 'https://example.com', + javascript: true, + waitUntil: 'networkidle0' + }, + {} + ) + const q = getUrl().searchParams + assert.equal(q.get('javascript'), 'true') + assert.equal(q.get('waitUntil'), 'networkidle0') + }, + { data: { markdown: '# Hello' } } + ) +}) + test('microlink_screenshot nests config (animated) and keeps device top-level', async t => { const handlers = captureTool(screenshot) await withStubbedRequest( @@ -128,6 +149,27 @@ test('microlink_screenshot nests config (animated) and keeps device top-level', ) }) +test('microlink_screenshot nests quality under screenshot', async t => { + const handlers = captureTool(screenshot) + await withStubbedRequest( + t, + async getUrl => { + await handlers.microlink_screenshot( + { + url: 'https://example.com', + screenshot: { type: 'jpeg', quality: 50 } + }, + {} + ) + const q = getUrl().searchParams + assert.equal(q.get('screenshot.type'), 'jpeg') + assert.equal(q.get('screenshot.quality'), '50') + assert.equal(q.get('quality'), null) + }, + { data: { screenshot: { url: 'https://cdn/x.jpg', type: 'jpeg' } } } + ) +}) + test('microlink_pdf nests config', async t => { const handlers = captureTool(pdf) await withStubbedRequest(t, async getUrl => {