Sign In

@wpconvert/mcp

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@wpconvert/mcp - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+211
src/capabilities.mjs
/**
* Pure MCP projections for developer-capabilities contract.
* No entitlement math — delegates to wpconvert/src/capabilities for labels/preflight.
*/
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const {
getConversionCapability,
formatPreflightSummary,
formatDenialMessage,
formatQuotaHuman,
reasonLabel,
actionLabel,
jobIsDownloadable,
} = require('wpconvert/src/capabilities');
const TOOL_QUOTA = 'wpconvert_quota';
const TOOL_CONVERT = 'wpconvert_convert_folder';
const TOOL_STATUS = 'wpconvert_check_status';
const TOOL_DOWNLOAD = 'wpconvert_download_result';
const TOOL_PREVIEW = 'wpconvert_create_preview';
const TOOL_EXPLAIN = 'wpconvert_explain_failure';
const STATUS_POLL_SECONDS = 15;
function downloadAvailable(capabilities) {
const dl = capabilities && capabilities.outputs && capabilities.outputs.download;
if (!dl || typeof dl !== 'object') return null;
return dl.available === true;
}
function buildSummary(quota) {
const conv = getConversionCapability(quota);
if (!conv) return undefined;
const caps = quota.capabilities || {};
const dl = downloadAvailable(caps);
return {
can_start: conv.can_start,
mode: conv.mode,
consumes: conv.consumes,
reason: conv.reason,
download_available: dl === true,
recommended_action: caps.recommended_action || null,
};
}
/**
* Recommend the next MCP tool from a quota snapshot.
*/
export function recommendedNextForQuota(quota) {
const conv = getConversionCapability(quota);
if (!conv) {
return {
tool: TOOL_CONVERT,
reason: 'Capabilities unavailable from server; server will enforce entitlement on submit.',
};
}
if (conv.can_start === false) {
const action = quota.capabilities && quota.capabilities.recommended_action;
const actionText = action ? actionLabel(action) : null;
return {
tool: TOOL_QUOTA,
reason: actionText
? `Conversion blocked. ${actionText}. Review quota before retrying.`
: 'Conversion blocked. Review quota and recommended action before retrying.',
};
}
const mode = conv.mode === 'preview_only' ? 'preview' : 'full';
return {
tool: TOOL_CONVERT,
reason: `Conversion allowed (${mode} mode). Call wpconvert_convert_folder when ready.`,
};
}
/**
* Project quota API response into MCP structured payload.
*/
export function projectQuotaResponse(quota) {
const conv = getConversionCapability(quota);
const capabilitiesAvailable = !!conv;
return {
ok: true,
quota,
capabilities_available: capabilitiesAvailable,
summary: buildSummary(quota),
recommended_next: recommendedNextForQuota(quota),
};
}
/**
* Recommend the next MCP tool from a job status snapshot.
*/
export function recommendedNextForStatus(status) {
const s = status || {};
const jobStatus = String(s.status || '').toLowerCase();
if (jobStatus === 'failed') {
return {
tool: TOOL_EXPLAIN,
reason: 'Job failed. Use wpconvert_explain_failure for details and recovery guidance.',
};
}
if (jobStatus === 'queued' || jobStatus === 'processing' || jobStatus === 'pending') {
return {
tool: TOOL_STATUS,
reason: 'Job still running. Poll wpconvert_check_status until done.',
retry_after_seconds: STATUS_POLL_SECONDS,
};
}
if (jobStatus === 'done') {
const previewOnly = s.preview_only === true || s.conversion_mode === 'preview_only';
const downloadable = jobIsDownloadable(s);
if (previewOnly || !downloadable) {
return {
tool: TOOL_PREVIEW,
reason: previewOnly
? 'Preview-only conversion. Use wpconvert_create_preview; download requires upgrade.'
: 'Download unavailable. Use wpconvert_create_preview or review quota.',
};
}
return {
tool: TOOL_DOWNLOAD,
reason: 'Conversion complete. Download theme.zip with wpconvert_download_result.',
};
}
return {
tool: TOOL_STATUS,
reason: 'Poll wpconvert_check_status for the latest job state.',
retry_after_seconds: STATUS_POLL_SECONDS,
};
}
/**
* Project job status into MCP structured payload (no fabricated fields).
*/
export function projectStatusResponse(status) {
const s = status || {};
const out = {
job_id: s.jobId || s.project_id || s.id || null,
project_id: s.project_id || s.jobId || s.id || null,
status: s.status || null,
recommended_next: recommendedNextForStatus(s),
};
if (s.progress != null) out.progress = s.progress;
if (s.error != null) out.error = s.error;
if (s.preview_only != null) out.preview_only = s.preview_only;
if (s.conversion_mode != null) out.conversion_mode = s.conversion_mode;
if (s.download_available != null) out.download_available = s.download_available;
if (s.preview_available != null) out.preview_available = s.preview_available;
return out;
}
/**
* Structured denial when preflight returns can_start === false.
*/
export function buildConversionDenial(quota) {
const caps = (quota && quota.capabilities) || {};
const conv = getConversionCapability(quota) || {};
const denialLines = formatDenialMessage(quota);
const message = denialLines.join(' ');
return {
ok: false,
error: {
code: 'conversion_not_available',
message,
reason: conv.reason || 'conversion_not_available',
recommended_action: caps.recommended_action || null,
retry_safe: false,
mode: conv.mode || null,
consumes: conv.consumes || null,
},
quota,
summary: buildSummary(quota),
recommended_next: recommendedNextForQuota(quota),
};
}
/**
* Human-readable quota text for MCP tool prose.
*/
export function formatQuotaText(quota) {
return formatQuotaHuman(quota).join('\n');
}
/**
* One-line preflight summary when conversion is allowed.
*/
export function formatAllowSummary(quota) {
return formatPreflightSummary(quota);
}
export {
TOOL_QUOTA,
TOOL_CONVERT,
TOOL_STATUS,
TOOL_DOWNLOAD,
TOOL_PREVIEW,
TOOL_EXPLAIN,
STATUS_POLL_SECONDS,
reasonLabel,
};
+2
-2
{
"name": "@wpconvert/mcp",
"version": "0.2.0",
"version": "0.3.0",
"description": "Model Context Protocol server for WPConvert.ai — lets an AI agent convert the current workspace folder into a WordPress theme.",

@@ -27,3 +27,3 @@ "license": "MIT",

"@modelcontextprotocol/sdk": "^1.0.0",
"wpconvert": "0.2.0"
"wpconvert": "0.3.0"
},

@@ -30,0 +30,0 @@ "keywords": [

@@ -18,3 +18,3 @@ # @wpconvert/mcp

| `wpconvert_explain_failure` | Return the failure reason for a failed job. |
| `wpconvert_quota` | Show remaining conversions / credits. |
| `wpconvert_quota` | Show quota, capabilities, and `recommended_next`. Call before converting. |

@@ -41,7 +41,47 @@ ## Configure (Cursor / Claude Desktop)

1. `wpconvert_convert_folder { "path": "./my-site", "type": "theme" }` → `jobId`
2. `wpconvert_check_status { "jobId": "..." }` (repeat until `done`; conversions take a few minutes)
3. `wpconvert_download_result { "jobId": "..." }` → saved theme `.zip`
4. Optional: `wpconvert_create_preview { "jobId": "..." }` → a WordPress Playground URL to view the theme live
**Quota-first (recommended):**
1. `wpconvert_quota` → review `capabilities` (conversion mode, `can_start`, download availability) and `recommended_next`
2. `wpconvert_convert_folder { "path": "./my-site", "type": "theme" }` → `jobId` + `idempotency_key` + `recommended_next`
3. `wpconvert_check_status { "jobId": "..." }` (repeat until `done`; follow `recommended_next` — usually poll every ~15s)
4. When `recommended_next.tool` is `wpconvert_download_result` → download the theme `.zip`
5. When preview-only or download locked → `wpconvert_create_preview` for a WordPress Playground URL (no ZIP)
If conversion is blocked (`can_start: false`), `wpconvert_convert_folder` returns a structured denial **before** zipping or submitting. Call `wpconvert_quota` to review `recommended_action`.
## Capabilities and structured responses
Tools return human-readable prose plus a trailing JSON block (same `ok()` / `fail()` pattern as before). Key fields:
| Field | Where | Meaning |
| --- | --- | --- |
| `quota` | `wpconvert_quota` | Full backend quota body (unknown future fields preserved) |
| `summary` | quota, denials | Projected `can_start`, `mode`, `consumes`, `download_available` |
| `capabilities_available` | quota | `false` on legacy backends without `capabilities.conversion` |
| `recommended_next` | all tools | `{ tool, reason, retry_after_seconds? }` — which MCP tool to call next |
| `next_action` | convert success | **String** (unchanged) — human guidance for agents |
| `idempotency_key` | convert | Omitted on preflight denial (no key generated) |
**Status `recommended_next` rules:**
- `queued` / `processing` → `wpconvert_check_status` (poll)
- `done` + downloadable → `wpconvert_download_result`
- `done` + preview-only / download locked → `wpconvert_create_preview` (never download)
- `failed` → `wpconvert_explain_failure`
## Idempotency recovery
- Leave `idempotency_key` blank for a **new intentional** conversion.
- If a prior `wpconvert_convert_folder` call timed out or returned an ambiguous network error, retry with the **exact** `idempotency_key` from that call — do not omit it and do not invent a new one.
- Do **not** reuse a key for changed files, options, or a deliberate new conversion.
- Preflight runs **after** path/plan validation but **before** key generation (when omitted), ZIP build, and submit — so blocked accounts never upload.
## Preview-only / download locked
Free developer previews are preview-only (Playground, no ZIP download). When `download_available` is `false` or `preview_only` is `true`, status and download tools recommend `wpconvert_create_preview` or `wpconvert_quota` — not download retry.
## Dependency pin
This package pins `wpconvert@0.3.0` exactly for shared capability helpers (`resolveConvertPreflight`, quota formatters). MCP package version remains **0.2.0** until a separate release review.
Billing, quotas, and refunds are identical to the dashboard and CLI for paid conversions. Free developer previews are preview-only (Playground, no ZIP download) and limited to 3 lifetime attempts per account. Secrets are excluded from the zip by default (set `includeEnv: true` only if you truly need them).

@@ -51,2 +51,13 @@ #!/usr/bin/env node

} = require('wpconvert/src/idempotency');
const { resolveConvertPreflight } = require('wpconvert/src/capabilities');
import {
projectQuotaResponse,
projectStatusResponse,
buildConversionDenial,
formatQuotaText,
formatAllowSummary,
recommendedNextForStatus,
TOOL_STATUS,
STATUS_POLL_SECONDS,
} from './capabilities.mjs';

@@ -80,3 +91,3 @@ const MULTIPART_CAP_MB = 50;

description:
'Zip a local folder (excluding node_modules, build output, and secrets by default) and start a WordPress theme conversion. Returns a jobId and an idempotency_key. Leave idempotency_key blank for a new intentional conversion. If a prior call may have timed out or returned an ambiguous network error, retry with the exact idempotency_key returned by that prior call — do not omit it and do not invent a new one. Do not reuse a key for changed files, options, or a deliberate new conversion. Once you have a jobId, poll wpconvert_check_status until "done", then call wpconvert_download_result or wpconvert_create_preview.',
'Zip a local folder (excluding node_modules, build output, and secrets by default) and start a WordPress theme conversion. Call wpconvert_quota first to review capabilities (mode, credits, download availability). Returns a jobId and an idempotency_key. Leave idempotency_key blank for a new intentional conversion. If a prior call may have timed out or returned an ambiguous network error, retry with the exact idempotency_key returned by that prior call — do not omit it and do not invent a new one. Do not reuse a key for changed files, options, or a deliberate new conversion. Once you have a jobId, poll wpconvert_check_status until "done", then follow recommended_next for download or preview.',
inputSchema: {

@@ -102,3 +113,4 @@ type: 'object',

name: 'wpconvert_check_status',
description: 'Check the status of a conversion job. Returns status (queued/processing/done/failed), progress, and whether a live preview is available. When done, you can call wpconvert_download_result and/or wpconvert_create_preview.',
description:
'Check the status of a conversion job. Returns status (queued/processing/done/failed), progress, download/preview availability, and recommended_next (which tool to call next). When done and downloadable, recommended_next points to wpconvert_download_result; for preview-only jobs it points to wpconvert_create_preview instead.',
inputSchema: {

@@ -112,3 +124,4 @@ type: 'object',

name: 'wpconvert_download_result',
description: 'Download a completed conversion to disk. Returns the saved file path. Optionally, use wpconvert_create_preview to view the theme in a live WordPress before/after downloading.',
description:
'Download a completed conversion to disk. Only use when wpconvert_check_status shows download_available and recommended_next is wpconvert_download_result. Returns the saved file path. For preview-only jobs, use wpconvert_create_preview or upgrade and re-convert instead.',
inputSchema: {

@@ -145,3 +158,4 @@ type: 'object',

name: 'wpconvert_quota',
description: 'Show the account\'s remaining conversions and PAYG credits.',
description:
'Show account quota, capabilities (conversion mode, can_start, download availability), and recommended_next. Call this before wpconvert_convert_folder to understand what the next conversion will consume.',
inputSchema: { type: 'object', properties: {} },

@@ -152,2 +166,13 @@ },

/**
* Validate a caller-supplied idempotency key without generating one.
*/
function validateSuppliedIdempotencyKey(args) {
const raw = args && args.idempotency_key;
if (raw == null || String(raw).trim() === '') return null;
return assertIdempotencyKey(String(raw), {
allowedPrefixes: [MCP_KEY_PREFIX, KEY_PREFIX],
});
}
/**
* Resolve the idempotency key for one convert_folder invocation.

@@ -172,3 +197,3 @@ * Caller-provided keys are used unchanged; otherwise generate once.

function successPayload({ jobId, projectId, status, idempotencyKey, replay, nextAction, extras = {} }) {
function successPayload({ jobId, projectId, status, idempotencyKey, replay, nextAction, recommendedNext, extras = {} }) {
return {

@@ -181,2 +206,3 @@ job_id: jobId,

next_action: nextAction,
recommended_next: recommendedNext,
...extras,

@@ -297,5 +323,5 @@ };

let idempotencyKey;
let validatedKey;
try {
({ key: idempotencyKey } = resolveIdempotencyKey(args));
validatedKey = validateSuppliedIdempotencyKey(args);
} catch (e) {

@@ -336,2 +362,29 @@ const supplied = args.idempotency_key != null ? String(args.idempotency_key).slice(0, MAX_KEY_LENGTH) : undefined;

const preflight = await resolveConvertPreflight(() => api.getQuota());
if (preflight.outcome === 'auth_error') {
return fail(redactSecrets(renderApiError(preflight.error)), errorPayload({
code: (preflight.error && preflight.error.code) || 'auth_error',
message: redactSecrets((preflight.error && preflight.error.message) || 'Authentication failed.'),
retrySafe: false,
reuseKey: false,
idempotencyKey: validatedKey || undefined,
}));
}
if (preflight.outcome === 'deny') {
const denial = buildConversionDenial(preflight.quota);
return fail(
denial.error.message + '\nCall wpconvert_quota to review capabilities and recommended action.',
denial
);
}
const preflightNote = preflight.outcome === 'allow'
? (formatAllowSummary(preflight.quota) ? `${formatAllowSummary(preflight.quota)}\n` : '')
: '';
const preflightWarning = preflight.warning ? `${preflight.warning}\n` : '';
const idempotencyKey = validatedKey ?? generateIdempotencyKey(MCP_KEY_PREFIX);
const zipBuffer = buildZipBuffer(files);

@@ -384,4 +437,12 @@ const zipMB = zipBuffer.length / (1024 * 1024);

const recommendedNext = {
tool: TOOL_STATUS,
reason: 'Conversion submitted. Poll wpconvert_check_status until done.',
retry_after_seconds: STATUS_POLL_SECONDS,
};
const text =
detectNote +
preflightWarning +
preflightNote +
(replay

@@ -393,3 +454,3 @@ ? `Recovered existing conversion request. jobId=${jobId}\n`

? 'Continue with wpconvert_check_status for this jobId — do not submit another conversion.'
: 'Poll wpconvert_check_status with this jobId until status is "done", then wpconvert_download_result or wpconvert_create_preview.') +
: 'Poll wpconvert_check_status with this jobId until status is "done", then follow recommended_next for download or preview.') +
previewNote;

@@ -406,2 +467,3 @@

nextAction,
recommendedNext,
extras: previewOnly

@@ -443,3 +505,3 @@ ? {

case 'theme_too_large_for_preview':
return 'This theme is too large for in-browser preview (over 30MB). Download it and test on a WordPress install instead.';
return e.message || 'This theme is too large for in-browser preview. Download it and test on a WordPress install instead.';
case 'idempotency_request_in_progress':

@@ -468,19 +530,59 @@ return 'The conversion request was accepted but its job ID is not available yet. Retry wpconvert_convert_folder with the same idempotency_key.';

const s = await api.getStatus(args.jobId);
const projected = projectStatusResponse(s);
const previewOnly = s.preview_only || s.conversion_mode === 'preview_only';
return ok(`status=${s.status}${s.progress != null ? ` progress=${s.progress}%` : ''}` +
const text =
`status=${s.status}${s.progress != null ? ` progress=${s.progress}%` : ''}` +
`${s.status === 'done' && s.preview_available ? '\npreview: available (call wpconvert_create_preview)' : ''}` +
`${previewOnly && s.status === 'done' ? `\n${PREVIEW_LOCKED_DOWNLOAD_COPY}` : ''}` +
`${s.status === 'failed' && s.error ? `\nerror: ${s.error}` : ''}`);
`${s.status === 'failed' && s.error ? `\nerror: ${s.error}` : ''}` +
`\nNext: ${projected.recommended_next.tool} — ${projected.recommended_next.reason}`;
return ok(text, projected);
}
case 'wpconvert_download_result': {
const info = await api.getDownload(args.jobId);
if (!info.download_url) return fail('No download URL available yet. Poll status until done.');
const dir = args.outDir ? path.resolve(process.cwd(), args.outDir) : process.cwd();
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fileName = info.name || `${args.jobId}-theme.zip`;
const outPath = path.join(dir, fileName);
const bytes = await api.fetchBinary(info.download_url);
fs.writeFileSync(outPath, bytes);
return ok(`Saved ${fileName} (${formatBytes(bytes.length)}) to ${outPath}`);
try {
const info = await api.getDownload(args.jobId);
if (!info.download_url) {
return fail(
'No download URL available yet. Poll status until done.',
{
ok: false,
error: { code: 'conversion_not_ready', message: 'No download URL available yet.' },
recommended_next: recommendedNextForStatus({ status: 'processing' }),
}
);
}
const dir = args.outDir ? path.resolve(process.cwd(), args.outDir) : process.cwd();
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fileName = info.name || `${args.jobId}-theme.zip`;
const outPath = path.join(dir, fileName);
const bytes = await api.fetchBinary(info.download_url);
fs.writeFileSync(outPath, bytes);
return ok(`Saved ${fileName} (${formatBytes(bytes.length)}) to ${outPath}`, {
ok: true,
job_id: args.jobId,
path: outPath,
size_bytes: bytes.length,
});
} catch (e) {
if (e && e.name === 'ApiError' && (e.code === 'upgrade_required' || e.details?.preview_only)) {
return fail(
PREVIEW_LOCKED_DOWNLOAD_COPY + '\nCall wpconvert_quota to review upgrade options.',
{
ok: false,
error: {
code: e.code || 'upgrade_required',
message: PREVIEW_LOCKED_DOWNLOAD_COPY,
reason: e.details?.reason || 'download_requires_upgrade',
retry_safe: false,
},
recommended_next: {
tool: 'wpconvert_quota',
reason: 'Download locked for preview-only job. Review quota and upgrade before re-converting.',
},
}
);
}
throw e;
}
}

@@ -490,7 +592,19 @@

const s = await api.getStatus(args.jobId);
if (s.status === 'failed') return fail(`Conversion failed: ${s.error || 'unknown error'}`);
if (s.status !== 'done') return fail(`Conversion is not ready yet (status=${s.status}). Poll status until "done", then retry.`);
if (s.status === 'failed') {
return fail(`Conversion failed: ${s.error || 'unknown error'}`, {
ok: false,
error: { code: 'conversion_failed', message: s.error || 'unknown error' },
recommended_next: recommendedNextForStatus(s),
});
}
if (s.status !== 'done') {
return fail(`Conversion is not ready yet (status=${s.status}). Poll status until "done", then retry.`, {
ok: false,
error: { code: 'conversion_not_ready', message: `status=${s.status}` },
recommended_next: recommendedNextForStatus(s),
});
}
const session = await api.createPlaygroundSession(args.jobId);
const expires = session.expires_at ? new Date(session.expires_at).toISOString() : null;
return ok(
const text =
`Preview ready. Open this URL in a browser to view the theme in WordPress Playground:\n` +

@@ -500,4 +614,10 @@ `${session.playground_url}\n` +

`Anyone with this URL can view the theme until it expires — treat it as sensitive.` +
`${session.warning ? `\nNote: ${session.warning}` : ''}`
);
`${session.warning ? `\nNote: ${session.warning}` : ''}`;
return ok(text, {
ok: true,
job_id: args.jobId,
playground_url: session.playground_url,
expires_at: expires,
warning: session.warning || null,
});
}

@@ -526,6 +646,5 @@

const q = await api.getQuota();
return ok(
`plan=${q.effectivePlan || 'unknown'} used=${q.current ?? '?'}/${q.max ?? '?'} ` +
`remaining=${q.remaining ?? '?'}${q.payg_credits != null ? ` payg=${q.payg_credits}` : ''}`
);
const projected = projectQuotaResponse(q);
const text = formatQuotaText(q);
return ok(text, projected);
}

@@ -532,0 +651,0 @@