Skip to content
Draft
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
1 change: 0 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,3 @@ save=false
shamefully-hoist=true
strict-peer-dependencies=false
unsafe-perm=true
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
34 changes: 34 additions & 0 deletions packages/core/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ import createGoogleClient from '@microlink/google'

type GoogleClient = ReturnType<typeof createGoogleClient>

/** Mirrors `@microlink/mql` ActionLocator / Action (kept local to avoid ESM/CJS type import issues). */
type ActionLocator =
| { selector: string }
| { role: string; name?: string }
| { text: string }
| { label: string }
| { placeholder: string }
| { testId: string }
| { alt: string }

export type Action =
| { type: 'inject'; styles?: string[]; scripts?: string[]; modules?: string[] }
| ({ type: 'click' } & ActionLocator)
| ({
type: 'wait'
timeout?: string | number
text?: string
request?: string
visible?: boolean
hidden?: boolean
} & Partial<ActionLocator>)
| ({ type: 'scroll'; x?: number; y?: number } & Partial<ActionLocator>)
| ({ type: 'fill'; value: string } & ActionLocator)
| { type: 'evaluate'; expression: string }
| ({ type: 'screenshot'; fullPage?: boolean } & Partial<ActionLocator>)
| {
type: 'pdf'
format?: string
scale?: number
margin?: string | Record<string, string | number>
printBackground?: boolean
}

/**
* Transport & top-level API query params. Unknown keys fall through
* to the API query string, so an index signature is provided.
Expand All @@ -10,6 +43,7 @@ interface Options {
apiKey?: string
endpoint?: string
headers?: Record<string, string>
actions?: Action[]
adblock?: boolean
animations?: boolean
audio?: boolean
Expand Down
12 changes: 10 additions & 2 deletions packages/core/test/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@ expectType<Promise<string[]>>(client.audios('https://example.com'))
async function assertions (): Promise<void> {
const screenshot = await client.screenshot('https://example.com', {
fullPage: true,
device: 'iPhone 11'
device: 'iPhone 11',
actions: [
{ type: 'fill', label: 'Email', value: 'user@example.com' },
{ type: 'click', role: 'button', name: 'Sign in' },
{ type: 'wait', text: 'Dashboard' }
]
})
expectType<string>(screenshot.url)

const pdf = await client.pdf('https://example.com', { format: 'A4' })
const pdf = await client.pdf('https://example.com', {
format: 'A4',
actions: [{ type: 'scroll', selector: '#pricing' }]
})
expectType<string>(pdf.url)

const logo = await client.logo('https://example.com', { square: true })
Expand Down
100 changes: 100 additions & 0 deletions packages/mcp/src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,110 @@ const fullShape = {
ping: toggledObjectSchema.optional()
}

const locatorFields = {
selector: z.string().min(1).optional(),
role: z.string().min(1).optional(),
name: z.string().min(1).optional(),
text: z.string().min(1).optional(),
label: z.string().min(1).optional(),
placeholder: z.string().min(1).optional(),
testId: z.string().min(1).optional(),
alt: z.string().min(1).optional()
}

const LOCATOR_STRATEGY_KEYS = [
'selector',
'role',
'text',
'label',
'placeholder',
'testId',
'alt'
]

const hasExactlyOneLocator = value => {
if (value.name && !value.role) return false
return LOCATOR_STRATEGY_KEYS.filter(key => value[key] != null).length === 1
}

const requireLocator = schema =>
schema.refine(hasExactlyOneLocator, {
message: 'Exactly one locator strategy is required (role may include name)'
})

const actionSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('inject'),
styles: stringOrStringArraySchema.optional(),
scripts: stringOrStringArraySchema.optional(),
modules: stringOrStringArraySchema.optional()
})
.strict(),
requireLocator(
z
.object({
type: z.literal('click'),
...locatorFields
})
.strict()
),
z
.object({
type: z.literal('wait'),
timeout: stringOrNumberSchema.optional(),
request: z.string().min(1).optional(),
visible: booleanSchema.optional(),
hidden: booleanSchema.optional(),
...locatorFields
})
.strict(),
z
.object({
type: z.literal('scroll'),
x: z.number().optional(),
y: z.number().optional(),
...locatorFields
})
.strict(),
requireLocator(
z
.object({
type: z.literal('fill'),
value: z.string(),
...locatorFields
})
.strict()
),
z
.object({
type: z.literal('evaluate'),
expression: z.string().min(1)
})
.strict(),
z
.object({
type: z.literal('screenshot'),
fullPage: booleanSchema.optional(),
...locatorFields
})
.strict(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional locators skip exclusivity checks

Medium Severity

click and fill run through requireLocator so conflicting strategies are rejected, but wait, scroll, and screenshot spread the same locatorFields with no refinement. Inputs with multiple strategies (or name without role) pass MCP validation and reach the API.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7277786. Configure here.

z
.object({
type: z.literal('pdf'),
format: z.string().min(1).optional(),
scale: z.number().optional(),
margin: pdfMarginSchema.optional(),
printBackground: booleanSchema.optional()
})
.strict()
])

// 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 = {
actions: z.array(actionSchema).min(1).optional(),
adblock: booleanSchema.optional(),
animations: booleanSchema.optional(),
cacheKey: z.string().min(1).optional(),
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/tools/function.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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`.',
'Prefer `actions` for ordered interactions before the function runs; legacy `waitForSelector`, `click`, and `scroll` still work.',
'Also combine with `javascript`, `waitUntil`, `headers`, and `proxy`.',
'Also returns `isFulfilled`, `profiling`, and `logging`. Mirrors the `microlink.function(url, code)` library method.'
].join(' '),
functionInputSchema,
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/tools/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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`.',
'Prefer `actions` for ordered interactions (click, wait, fill, …); legacy `waitForSelector` still works.',
'Also combine with `javascript`, `waitUntil`, `headers`, and `proxy`.',
'Mirrors the `microlink.html(url)` library method.'
].join(' '),
htmlInputSchema,
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/tools/pdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function pdf (server) {
'Generate a PDF of any public URL via Microlink and return the asset object (`url`, `type`, `size`, ...) with a permanent CDN URL.',
'Pass `pdf: true` for defaults or `pdf: { ... }` for options; `pdf: {}` is treated as `true`.',
'Use `pdf.format` ("A4" default, "Letter", "Legal", ...), `pdf.landscape`, `pdf.margin` (string or top/bottom/left/right object), `pdf.scale` (0.1-2.0), `pdf.pageRanges` ("1-5"), or `pdf.width`/`pdf.height`.',
'Combine with `styles`, `scripts`, `modules`, `mediaType`, `waitForSelector`, and `waitUntil` for full control.',
'Prefer `actions` (ordered browser steps: inject, click, wait, scroll, fill, pdf, …) with semantic locators or CSS `selector`.',
'Legacy `styles`, `scripts`, `modules`, `waitForSelector`, and `waitUntil` still work; also `mediaType`.',
'Mirrors the `microlink.pdf(url, options)` library method.'
].join(' '),
pdfInputSchema,
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/tools/screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export function screenshot (server) {
'Use `screenshot.fullPage` to capture the whole scrollable page.',
'Use `screenshot.animated` to capture an animated screenshot (GIF/MP4) instead of a still image.',
'Use `screenshot.element` (CSS selector) to capture a specific element, `screenshot.type` for format ("jpeg", default "png"), `screenshot.omitBackground` for transparency, `screenshot.overlay` for browser chrome, `screenshot.palette` to also extract dominant colors, or `screenshot.codeScheme` to theme code pages.',
'Combine with `device`, `viewport`, `click`, `scroll`, `styles`, `scripts`, `modules`, `waitForSelector`, `waitForTimeout`, `waitUntil`, `colorScheme`, and `mediaType`.',
'Prefer `actions` (ordered browser steps: inject, click, wait, scroll, fill, screenshot, …) with semantic locators (`role`+`name`, `label`, `text`, `testId`) or CSS `selector` — e.g. `actions: [{ type: "click", role: "button", name: "Accept" }, { type: "wait", timeout: "1s" }]`.',
'Legacy `click`, `scroll`, `styles`, `scripts`, `modules`, `waitForSelector`, and `waitForTimeout` still work; also `device`, `viewport`, `waitUntil`, `colorScheme`, and `mediaType`.',
'Mirrors the `microlink.screenshot(url, options)` library method.'
].join(' '),
screenshotInputSchema,
Expand Down
78 changes: 78 additions & 0 deletions packages/mcp/test/schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,84 @@ test('function schema accepts code with shared browser options', () => {
assert.equal(result.data.click, '#accept')
})

test('screenshot schema accepts actions with semantic locators', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://app.example.com/login',
screenshot: true,
actions: [
{ type: 'fill', label: 'Email', value: 'user@example.com' },
{ type: 'click', role: 'button', name: 'Sign in' },
{ type: 'wait', text: 'Dashboard' },
{ type: 'screenshot', fullPage: true }
]
})

assert.equal(result.success, true)
assert.equal(result.data.actions.length, 4)
assert.equal(result.data.actions[0].type, 'fill')
assert.equal(result.data.actions[1].role, 'button')
})

test('screenshot schema rejects click without a locator', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://microlink.io',
actions: [{ type: 'click' }]
})

assert.equal(result.success, false)
})

test('screenshot schema rejects fill without a locator', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://microlink.io',
actions: [{ type: 'fill', value: 'user@example.com' }]
})

assert.equal(result.success, false)
})

test('screenshot schema rejects click with conflicting locators', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://microlink.io',
actions: [{ type: 'click', selector: '#submit', text: 'Submit' }]
})

assert.equal(result.success, false)
})

test('screenshot schema rejects fill with name and no role', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://microlink.io',
actions: [{ type: 'fill', name: 'Email', value: 'user@example.com' }]
})

assert.equal(result.success, false)
})

test('screenshot schema rejects actions with unknown type', () => {
const result = screenshotInputSchema.safeParse({
url: 'https://microlink.io',
actions: [{ type: 'drag', selector: '#box' }]
})

assert.equal(result.success, false)
})

test('pdf schema accepts actions with inject and wait', () => {
const result = pdfInputSchema.safeParse({
url: 'https://microlink.io',
pdf: true,
actions: [
{ type: 'inject', styles: ['.banner { display: none }'] },
{ type: 'wait', timeout: '1s' },
{ type: 'pdf', format: 'A4' }
]
})

assert.equal(result.success, true)
assert.equal(result.data.actions[0].type, 'inject')
})

test('metadata schema accepts palette and waitUntil', () => {
const result = metadataInputSchema.safeParse({
url: 'https://microlink.io',
Expand Down
33 changes: 33 additions & 0 deletions packages/mql/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,38 @@ type ScreenshotOptions = {
type?: 'jpeg' | 'png'
}

export type ActionLocator =
| { selector: string }
| { role: string; name?: string }
| { text: string }
| { label: string }
| { placeholder: string }
| { testId: string }
| { alt: string }

export type Action =
| { type: 'inject'; styles?: string[]; scripts?: string[]; modules?: string[] }
| ({ type: 'click' } & ActionLocator)
| ({
type: 'wait'
timeout?: string | number
text?: string
request?: string
visible?: boolean
hidden?: boolean
} & Partial<ActionLocator>)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait type allows conflicting locators

Medium Severity

The wait action declares text on the base object and also intersects Partial&lt;ActionLocator&gt;, which already includes text. Because text is always available on the base, TypeScript accepts combining it with another locator strategy such as selector or label, which violates the one-strategy rule used elsewhere.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7277786. Configure here.

| ({ type: 'scroll'; x?: number; y?: number } & Partial<ActionLocator>)
| ({ type: 'fill'; value: string } & ActionLocator)
| { type: 'evaluate'; expression: string }
| ({ type: 'screenshot'; fullPage?: boolean } & Partial<ActionLocator>)
| {
type: 'pdf'
format?: string
scale?: number
margin?: string | PdfMargin
printBackground?: boolean
}

type MqlClientOptions = {
apiKey?: string
endpoint?: string
Expand Down Expand Up @@ -78,6 +110,7 @@ type MqlQueryOptions = {
}

export type MicrolinkApiOptions = {
actions?: Action[]
adblock?: boolean
animations?: boolean
audio?: boolean
Expand Down
16 changes: 16 additions & 0 deletions packages/mql/test/get-api-url.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,19 @@ test('undefined', t => {
})
)
})

test('actions flatten to dotted keys', t => {
t.snapshot(
mql.getApiUrl('https://app.example.com/login', {
meta: false,
screenshot: true,
actions: [
{ type: 'fill', label: 'Email', value: 'user@example.com' },
{ type: 'fill', label: 'Password', value: 'secret' },
{ type: 'click', role: 'button', name: 'Sign in' },
{ type: 'wait', text: 'Dashboard' },
{ type: 'screenshot', fullPage: true }
]
})
)
})
Loading