baconservices-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,19 +18,22 @@ spending limits.
18
18
 
19
19
  | Tool | Does | Price (2026-10-17) |
20
20
  |---|---|---|
21
- | `remove_background` | Removes the background from one photo; returns a transparent PNG at the photo's size | 0.20 USDC |
21
+ | `remove_background` | Removes the background from one photo; returns a transparent PNG at the photo's size | 0.05 USDC |
22
22
  | `optimize_image` | Resizes so the longest side ≤ `max_width` (64–4096, default 1600) and encodes WebP at `quality` (40–95, default 80) | 0.01 USDC |
23
23
  | `image_palette` | The colours one image is mostly made of, as JSON: up to `count` (1–12, default 6) hex colours with their share, `include_neutrals` (default true) | 0.01 USDC |
24
24
  | `brand_from_url` | Reads one public web page (`url`) and returns its brand as JSON: name, description, logo URLs, colours, fonts, social profiles. No JavaScript runs; robots.txt is respected | 0.03 USDC |
25
- | `vectorize_image` | Traces one image into an SVG of flat-colour paths: `colors` (2–32, default 16), `detail` (`smooth`, `balanced` or `detailed`, default `balanced`). Best for logos and icons | 0.05 USDC |
25
+ | `page_metadata` | Reads one public web page (`url`) and returns its SEO and link-preview metadata as JSON: title and description with lengths, canonical, redirects, robots rules, Open Graph and Twitter cards, headings, JSON-LD types, and a list of issues. Nothing it links to is fetched; robots.txt is respected | 0.01 USDC |
26
+ | `accessibility_check` | An automated WCAG check of one public web page (`url`) with axe-core: `standard` (`wcag22aa` default, or `wcag21aa`), `viewport` (`desktop` default, or `mobile`). JSON: failing rules with WCAG criteria, element counts and examples, what needs review, and the criteria a person must test. The page's own scripts don't run. Not a certification of WCAG or ADA conformance | 0.05 USDC |
27
+ | `vectorize_image` | Traces one image into an SVG of flat-colour paths: `colors` (2–32, default 16), `detail` (`smooth`, `balanced` or `detailed`, default `balanced`). Best for logos and icons; a photo's SVG is larger than the photo, and `colors` sets the size | 0.05 USDC |
28
+ | `clean_svg` | Cleans and simplifies one SVG (`svg` markup, `svg_url` or `svg_path`, up to 1 MB) and returns it as JSON with a report. Rebuilt from an allowlist: scripts, event handlers, animation, links and outside references are removed; editor data, unused definitions and hidden or empty elements go; numbers are rounded to `precision` (1–6, default 3) and paths rewritten short. `simplify` (`none` default, `light`, `strong`) moves points on straight runs; `keep_ids` (default false) keeps ids and classes | 0.01 USDC |
26
29
 
27
30
  Image tools take PNG, JPEG or WebP up to 8 MB, as `image_url` (public https) or `image_path` (a
28
31
  local file). `remove_background`, `optimize_image` and `vectorize_image` return a signed download
29
- link (valid 24 hours); `image_palette` and `brand_from_url` return their JSON result in the tool result. Add
30
- `save_to` to write either kind to disk. Prices come from Bacon's live catalog, and each paid tool
32
+ link (valid 24 hours); `image_palette`, `brand_from_url`, `page_metadata`, `accessibility_check` and `clean_svg` return their JSON result in the tool result. Add
33
+ `save_to` to write either kind to disk. A JSON result over 256 KB (a large cleaned SVG, say) is not shown, so pass `save_to` for one. Prices come from Bacon's live catalog, and each paid tool
31
34
  states its current price in its description.
32
35
 
33
- `image_palette`, `brand_from_url` and `vectorize_image` appear once Bacon's catalog publishes
36
+ `image_palette`, `brand_from_url`, `page_metadata`, `accessibility_check`, `vectorize_image` and `clean_svg` appear once Bacon's catalog publishes
34
37
  them with contract versions. A service the installed package doesn't understand is listed by `list_services` but
35
38
  can't be called until you update the package.
36
39
 
@@ -152,7 +155,7 @@ All of these checks happen before anything is signed:
152
155
  cap is set to your per-call limit as a second lock.
153
156
  - **Funds.** It reads your USDC balance first. If it's too low, or the balance can't be read,
154
157
  you get a plain message and nothing is signed.
155
- - **Your approval, where possible.** If the MCP host supports it, you're asked *"Pay 0.2 USDC on
158
+ - **Your approval, where possible.** If the MCP host supports it, you're asked *"Pay 0.05 USDC on
156
159
  Base to Bacon Services to run remove_background?"* Not every host can ask, so the limits are
157
160
  the real boundary.
158
161
  - **No double payments.** Each purchase signs one payment for one request. If the connection drops,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "baconservices-mcp",
3
- "version": "0.2.0",
4
- "description": "MCP server that lets an AI agent buy Bacon Services tools per call in USDC over x402: remove a background, optimize an image for the web, read an image's colour palette, read a brand from a URL, trace an image into an SVG. With local spending limits.",
3
+ "version": "0.4.0",
4
+ "description": "MCP server that lets an AI agent buy Bacon Services tools per call in USDC over x402: remove a background, optimize an image for the web, read an image's colour palette, read a brand from a URL, read a page's SEO metadata, run an automated WCAG accessibility check, trace an image into an SVG, clean up an SVG. With local spending limits.",
5
5
  "mcpName": "io.github.afmontoya/baconservices-mcp",
6
6
  "type": "module",
7
7
  "bin": {
package/src/contracts.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // The live catalog decides what is FOR SALE (and at what price); this table decides what this
4
4
  // package will EXECUTE. A capability becomes a paid tool only when its catalog entry matches one
5
5
  // of these contracts exactly. A contract is the capability id, its behaviour `version`, its
6
- // `input_version` and `output_version`, an input type (one image, or one web page URL) with the
6
+ // `input_version` and `output_version`, an input type (one image, one web page URL, or one SVG) with the
7
7
  // same option names, types, bounds and allowed values, an output (an artifact file of one MIME type, or a JSON
8
8
  // result), synchronous execution, and declared limits. Anything else — a new capability, a new
9
9
  // version, a changed schema, a catalog contract_version this package does not know — is still
@@ -14,9 +14,13 @@ export const SUPPORTED_CATALOG_VERSIONS = [1];
14
14
 
15
15
  export const IMAGE_FORMATS = ['image/png', 'image/jpeg', 'image/webp'];
16
16
 
17
+ /** The input formats each input type may declare. A URL input declares the page's, unchecked here. */
18
+ const FORMATS = { image: { allowed: IMAGE_FORMATS, label: 'PNG/JPEG/WebP' }, svg: { allowed: ['image/svg+xml'], label: 'SVG' } };
19
+
17
20
  const INPUTS = {
18
21
  image: { image_url: { type: 'string' }, image_base64: { type: 'string' } },
19
22
  url: { url: { type: 'string' } },
23
+ svg: { svg: { type: 'string' }, svg_url: { type: 'string' } },
20
24
  };
21
25
 
22
26
  export const CONTRACTS = [
@@ -81,6 +85,35 @@ export const CONTRACTS = [
81
85
  output: { kind: 'json', mime: 'application/json' },
82
86
  execution: 'sync',
83
87
  },
88
+ {
89
+ tool: 'page_metadata',
90
+ id: 'page-metadata',
91
+ version: 1,
92
+ inputVersion: 1,
93
+ outputVersion: 1,
94
+ title: 'Page metadata',
95
+ summary: 'Reads one public web page and returns what it tells search engines and link previews, as JSON: title and description with lengths, canonical, redirects, robots rules, Open Graph and Twitter cards, headings, JSON-LD types and a list of SEO issues. No JavaScript runs; robots.txt is respected.',
96
+ input: 'url',
97
+ options: {},
98
+ output: { kind: 'json', mime: 'application/json' },
99
+ execution: 'sync',
100
+ },
101
+ {
102
+ tool: 'accessibility_check',
103
+ id: 'accessibility-check',
104
+ version: 1,
105
+ inputVersion: 1,
106
+ outputVersion: 1,
107
+ title: 'Accessibility check',
108
+ summary: 'An automated WCAG check of one public web page (axe-core), as JSON: each failing rule with its impact, WCAG criteria, element count and examples; what needs review; and the criteria a person must test. Not a certification of WCAG or ADA conformance. Scripts on the page do not run; robots.txt is respected.',
109
+ input: 'url',
110
+ options: {
111
+ standard: { type: 'string', enum: ['wcag22aa', 'wcag21aa'], default: 'wcag22aa', description: 'wcag22aa: WCAG 2.2 A and AA. wcag21aa: WCAG 2.1 A and AA.' },
112
+ viewport: { type: 'string', enum: ['desktop', 'mobile'], default: 'desktop', description: 'desktop: 1280 x 800. mobile: 390 x 844, touch.' },
113
+ },
114
+ output: { kind: 'json', mime: 'application/json' },
115
+ execution: 'sync',
116
+ },
84
117
  {
85
118
  tool: 'vectorize_image',
86
119
  id: 'vectorize',
@@ -88,15 +121,32 @@ export const CONTRACTS = [
88
121
  inputVersion: 1,
89
122
  outputVersion: 1,
90
123
  title: 'Vectorize image',
91
- summary: 'Traces one PNG, JPEG or WebP image into an SVG of filled paths with up to colors flat colours. Best for logos, icons and flat artwork; a photo comes back as a stylised poster. The SVG keeps the image\'s width and height (capped at 4096).',
124
+ summary: 'Traces one PNG, JPEG or WebP image into an SVG of filled paths with up to colors flat colours. Best for logos, icons and flat artwork; a photo comes back as a stylised poster, and its SVG is larger than the photo (often two to three times a PNG of it at 16 colours; colors sets the size). The SVG keeps the image\'s width and height (capped at 4096).',
92
125
  input: 'image',
93
126
  options: {
94
- colors: { type: 'integer', minimum: 2, maximum: 32, default: 16, description: 'How many flat colours to trace with. 2 is black and white.' },
127
+ colors: { type: 'integer', minimum: 2, maximum: 32, default: 16, description: 'How many flat colours to trace with. 2 is black and white. Fewer colours make a much smaller file; on a photo, 8 is about 40% of 16.' },
95
128
  detail: { type: 'string', enum: ['smooth', 'balanced', 'detailed'], default: 'balanced', description: 'smooth: fewer, simpler shapes. detailed: closer to the source, larger file.' },
96
129
  },
97
130
  output: { kind: 'artifact', mime: 'image/svg+xml' },
98
131
  execution: 'sync',
99
132
  },
133
+ {
134
+ tool: 'clean_svg',
135
+ id: 'svg-cleanup',
136
+ version: 1,
137
+ inputVersion: 1,
138
+ outputVersion: 1,
139
+ title: 'Clean SVG',
140
+ summary: 'Cleans and simplifies one SVG and returns it as JSON, with a report (bytes saved, what was removed, warnings). The SVG is rebuilt from an allowlist: scripts, event handlers, animation, links and references outside the file are removed; editor data, unused definitions, hidden and empty elements go; numbers are rounded and path data rewritten short. It looks the same unless simplify is light or strong. The cleaned SVG is result.svg.',
141
+ input: 'svg',
142
+ options: {
143
+ precision: { type: 'integer', minimum: 1, maximum: 6, default: 3, description: 'Decimals kept on coordinates. A drawing under 100 units across keeps one more per factor of ten.' },
144
+ simplify: { type: 'string', enum: ['none', 'light', 'strong'], default: 'none', description: 'none: looks the same. light or strong: points on straight runs move within 0.05% or 0.2% of the drawing\'s diagonal.' },
145
+ keep_ids: { type: 'boolean', default: false, description: 'Keep every id and class, for an SVG a page\'s own CSS or script uses.' },
146
+ },
147
+ output: { kind: 'json', mime: 'application/json' },
148
+ execution: 'sync',
149
+ },
100
150
  ];
101
151
 
102
152
  export function contractId(id, version) {
@@ -194,9 +244,10 @@ export function checkEntry(entry, contract, expected) {
194
244
  if (!positive(limits.max_bytes)) {
195
245
  reasons.push('no maximum input size is declared');
196
246
  }
247
+ const formats = FORMATS[contract.input];
197
248
  if (!Array.isArray(limits.formats) || limits.formats.length === 0
198
- || (contract.input === 'image' && limits.formats.some((f) => !IMAGE_FORMATS.includes(f)))) {
199
- reasons.push(`input formats are missing${contract.input === 'image' ? ' or not PNG/JPEG/WebP' : ''}`);
249
+ || (formats && limits.formats.some((f) => !formats.allowed.includes(f)))) {
250
+ reasons.push(`input formats are missing${formats ? ` or not ${formats.label}` : ''}`);
200
251
  }
201
252
  if (!legacyCatalog && !positive(limits.max_request_bytes)) {
202
253
  reasons.push('no maximum request size is declared');
package/src/ledger.js CHANGED
@@ -24,11 +24,14 @@ const COUNTS = new Set(['reserved', 'spent', 'uncertain']);
24
24
  export class LedgerError extends Error {}
25
25
 
26
26
  export class Ledger {
27
- constructor(dir, { now = () => Date.now() } = {}) {
27
+ constructor(dir, { now = () => Date.now(), openLock = openSync, platform = process.platform, lockWaitMs = LOCK_WAIT_MS } = {}) {
28
28
  this.dir = dir;
29
29
  this.file = join(dir, 'spend-ledger.json');
30
30
  this.lockFile = join(dir, 'spend-ledger.lock');
31
31
  this.now = now;
32
+ this.openLock = openLock;
33
+ this.platform = platform;
34
+ this.lockWaitMs = lockWaitMs;
32
35
  }
33
36
 
34
37
  /** Total that counts against the daily ceiling on this network, in atomic units. */
@@ -132,11 +135,18 @@ export class Ledger {
132
135
  const started = Date.now();
133
136
  for (;;) {
134
137
  try {
135
- closeSync(openSync(this.lockFile, 'wx'));
138
+ closeSync(this.openLock(this.lockFile, 'wx'));
136
139
  break;
137
140
  } catch (error) {
138
- if (error.code !== 'EEXIST') {
139
- throw new LedgerError(`The spend ledger lock ${this.lockFile} cannot be created (${error.code}).`);
141
+ // On Windows, opening a lock file that another process is still deleting fails with
142
+ // EPERM, not EEXIST. Only a successful open takes the lock, so waiting is safe; an
143
+ // EPERM that outlasts the wait is a real permission problem and refuses like any other.
144
+ const held = error.code === 'EEXIST';
145
+ const contended = held || (error.code === 'EPERM' && this.platform === 'win32');
146
+ if (!contended || Date.now() - started > this.lockWaitMs) {
147
+ throw new LedgerError(held
148
+ ? `The spend ledger is locked by another process (${this.lockFile}).`
149
+ : `The spend ledger lock ${this.lockFile} cannot be created (${error.code}).`);
140
150
  }
141
151
  try {
142
152
  if (Date.now() - statSync(this.lockFile).mtimeMs > LOCK_STALE_MS) {
@@ -144,10 +154,7 @@ export class Ledger {
144
154
  continue;
145
155
  }
146
156
  } catch {
147
- continue;
148
- }
149
- if (Date.now() - started > LOCK_WAIT_MS) {
150
- throw new LedgerError(`The spend ledger is locked by another process (${this.lockFile}).`);
157
+ // Gone already, or it cannot be removed: wait, so the deadline still applies.
151
158
  }
152
159
  await new Promise((resolve) => setTimeout(resolve, 20));
153
160
  }
package/src/payment.js CHANGED
@@ -160,7 +160,9 @@ export class Purchaser {
160
160
  const endpoint = endpointFor(apiUrl, service.id);
161
161
 
162
162
  // 1. Input, options, destination and the caller's price ceiling: all before any request.
163
- const image = contract.input === 'url' ? prepareUrl(args, service.entry.input) : prepareImage(args, service.entry.limits);
163
+ const image = contract.input === 'url' ? prepareUrl(args, service.entry.input)
164
+ : contract.input === 'svg' ? prepareSvg(args, service.entry)
165
+ : prepareImage(args, service.entry.limits);
164
166
  const options = prepareOptions(args, contract);
165
167
  const saveTo = prepareSaveTo(args);
166
168
  let maxPrice = null;
@@ -536,6 +538,64 @@ export function prepareImage(args, limits) {
536
538
  return { body: { image_base64: bytes.toString('base64') }, identity: `sha256:${sha256(bytes)}` };
537
539
  }
538
540
 
541
+ /**
542
+ * Exactly one of svg (the markup), svg_url (https) or svg_path (a local file, sent as svg), within
543
+ * Bacon's size limit. The file must be UTF-8 and hold an <svg>; Bacon checks the markup fully
544
+ * (well-formed, no entities, an <svg> root) before it quotes a price.
545
+ */
546
+ export function prepareSvg(args, entry) {
547
+ const given = ['svg', 'svg_url', 'svg_path'].filter((name) => typeof args[name] === 'string' && args[name].trim() !== '');
548
+ if (given.length !== 1) {
549
+ throw new Refusal('Give exactly one of svg, svg_url or svg_path.');
550
+ }
551
+ const { max_bytes: maxBytes } = entry.limits;
552
+ if (given[0] === 'svg_url') {
553
+ const raw = args.svg_url.trim();
554
+ const maxLength = entry.input?.properties?.svg_url?.maxLength ?? 2048;
555
+ if (raw.length > maxLength) {
556
+ throw new Refusal(`svg_url is ${raw.length} characters; Bacon takes at most ${maxLength}.`);
557
+ }
558
+ let url;
559
+ try {
560
+ url = new URL(raw);
561
+ } catch {
562
+ throw new Refusal('svg_url is not a URL.');
563
+ }
564
+ if (url.protocol !== 'https:') {
565
+ throw new Refusal('svg_url must be a public https URL.');
566
+ }
567
+ return { body: { svg_url: url.href }, identity: `url:${url.href}` };
568
+ }
569
+ let markup;
570
+ if (given[0] === 'svg') {
571
+ markup = args.svg;
572
+ } else {
573
+ const path = resolve(args.svg_path.trim());
574
+ let size;
575
+ try {
576
+ size = statSync(path).size;
577
+ } catch {
578
+ throw new Refusal(`svg_path ${path} cannot be read.`);
579
+ }
580
+ if (size > maxBytes) {
581
+ throw new Refusal(`svg_path is ${size} bytes; Bacon takes at most ${maxBytes}.`);
582
+ }
583
+ try {
584
+ markup = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
585
+ } catch {
586
+ throw new Refusal('svg_path must be a UTF-8 text file.');
587
+ }
588
+ }
589
+ const bytes = Buffer.byteLength(markup);
590
+ if (bytes > maxBytes) {
591
+ throw new Refusal(`The SVG is ${bytes} bytes; Bacon takes at most ${maxBytes}.`);
592
+ }
593
+ if (!markup.includes('<svg')) {
594
+ throw new Refusal(`${given[0]} does not hold an <svg> element.`);
595
+ }
596
+ return { body: { svg: markup }, identity: `sha256:${sha256(markup)}` };
597
+ }
598
+
539
599
  /**
540
600
  * One public web page for a URL capability: http(s), no user:pass@, ports 80/443 only, within
541
601
  * the catalog's maxLength. Bacon checks it again (and that it is public) before quoting.
package/src/server.js CHANGED
@@ -11,10 +11,10 @@ import { CONTRACTS } from './contracts.js';
11
11
  import { loadConfig } from './config.js';
12
12
  import { Ledger } from './ledger.js';
13
13
  import { formatUsdc } from './money.js';
14
- import { Purchaser, Refusal } from './payment.js';
14
+ import { HARD_MAX_RESULT_BYTES, Purchaser, Refusal } from './payment.js';
15
15
  import { balanceReader, loadWallet, redact } from './wallet.js';
16
16
 
17
- export const VERSION = '0.2.0';
17
+ export const VERSION = '0.4.0';
18
18
  const CATALOG_TTL_MS = 5 * 60 * 1000;
19
19
 
20
20
  const FREE_TOOLS = [
@@ -133,14 +133,24 @@ export function createBaconServer({ env = process.env, fetch = globalThis.fetch,
133
133
  const network = config.network;
134
134
  const options = Object.fromEntries(Object.entries(contract.options).map(([name, spec]) => [name, { ...spec }]));
135
135
  const price = `Costs ${service.price.usdc} USDC per call${network.testnet ? ' (test USDC)' : ' (real money)'} on ${network.label}, paid from the buyer's wallet over x402 after the buyer's spending limits are checked.`;
136
+ // A service may declare a larger JSON result than this package shows: say so before anyone pays.
137
+ const unshown = limits.max_response_bytes > HARD_MAX_RESULT_BYTES
138
+ ? ` A result over ${HARD_MAX_RESULT_BYTES / 1024} KB is not shown; pass save_to to keep it.` : '';
136
139
  const output = contract.output.kind === 'json'
137
- ? 'Output: JSON, returned in the tool result.'
140
+ ? `Output: JSON, returned in the tool result.${unshown}`
138
141
  : `Output: ${contract.output.mime}, as a download link (and a file with save_to).`;
139
142
  let input;
140
143
  let source;
141
144
  if (contract.input === 'url') {
142
145
  input = 'Give url: one public web page (http or https, standard ports).';
143
146
  source = { url: { type: 'string', description: 'A public web page, usually the homepage.', maxLength: service.entry.input.properties.url.maxLength ?? 2048 } };
147
+ } else if (contract.input === 'svg') {
148
+ input = `Input: one SVG, up to ${Math.floor(limits.max_bytes / 1048576)} MB. Give exactly one of svg, svg_url or svg_path.`;
149
+ source = {
150
+ svg: { type: 'string', description: 'The SVG markup itself.' },
151
+ svg_url: { type: 'string', description: 'A public https URL of an SVG file.', maxLength: service.entry.input.properties.svg_url.maxLength ?? 2048 },
152
+ svg_path: { type: 'string', description: 'A local SVG file to send (read by this server as UTF-8 text).' },
153
+ };
144
154
  } else {
145
155
  const formats = limits.formats.map((f) => f.replace('image/', '').toUpperCase()).join(', ');
146
156
  input = `Input: ${formats}, up to ${Math.floor(limits.max_bytes / 1048576)} MB${limits.max_dimension ? `, at most ${limits.max_dimension} px` : ''}. Give exactly one of image_url or image_path.`;