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
3 changes: 2 additions & 1 deletion packages/core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const SCREENSHOT_KEYS = [
'optimizeForSpeed',
'codeScheme',
'animated',
'palette'
'palette',
'quality'
]

const PDF_KEYS = [
Expand Down
32 changes: 25 additions & 7 deletions packages/mcp/src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
Kikobeats marked this conversation as resolved.
type: z.enum(['jpeg', 'png']).optional()
})
.strict()
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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()
Expand All @@ -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(),
Expand Down Expand Up @@ -392,6 +409,7 @@ export const searchInputSchema = z
.strict()

export const functionInputSchema = baseSchema
.extend(visualSchema)
.extend({
code: z.string().min(1)
})
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tools/function.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tools/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tools/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/tools/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/mcp/test/schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'
import {
audioInputSchema,
extractInputSchema,
functionInputSchema,
markdownInputSchema,
metadataInputSchema,
logoInputSchema,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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'
Expand Down
42 changes: 42 additions & 0 deletions packages/mcp/test/tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 => {
Expand Down