markaDocs

SDK

@joinmarka/sdk — a typed TypeScript client for /api/v1, plus a local MCP server built on top of it.

@joinmarka/sdk is a thin, typed client for Marka's public API. Its request and response types come from the same contract that generates the API reference, so the SDK cannot describe an endpoint the API does not have.

npm install @joinmarka/sdk

This guide is for the 0.2 releases. Coming from 0.1.0? Several request and response fields were renamed and the server no longer accepts the old names, so read Upgrading from 0.1.0 first.

ESM only. MarkaClient runs on Node.js 20.19 or newer. @joinmarka/sdk/mcp and the marka-mcp-stdio command need Node.js 22.13 or newer, which is a requirement of @mastra/core; the package's engines field (>=20.19.0) describes the client only. The package is published from DevinoSolutions/marka-node, which carries the built artifact; the source lives in the Marka monorepo.

Quickstart

import { MarkaClient } from "@joinmarka/sdk";

const marka = new MarkaClient({ apiKey: process.env.MARKA_API_KEY! });

const { user, plan } = await marka.getMe();
console.log(`${user.email} — ${plan.tier}`);

const { results } = await marka.search({ q: "road bike", limit: 5 });
for (const listing of results) {
  // Integer minor units — the SDK converts nothing.
  console.log(listing.title, listing.price, listing.currency);
}

Configuration

new MarkaClient({
  apiKey: "mk_…",
  baseUrl: "http://localhost:3001", // optional
  fetch: myFetch,                   // optional, injectable transport
});

baseUrl is an origin

baseUrl is scheme and host only. The /api mount point and the /v1/… path are appended by the client. Passing https://joinmarka.com/api or .../api/v1 produces a doubled path that 404s. It defaults to https://joinmarka.com; override it to develop against a local web app.

Configuration is constructor arguments only — the SDK never reads process.env, so the same instance is safe in a browser, a worker, or a multi-tenant server.

Methods

One method per operation, named for the operation id.

MethodEndpoint
getMe()GET /v1/me
listListings(query?)GET /v1/listings
createListing(body)POST /v1/listings
getListing(id)GET /v1/listings/{id}
updateListing(id, body)PATCH /v1/listings/{id}
publishListing(id)POST /v1/listings/{id}/publish
search(query?)GET /v1/search
listCategories(query?)GET /v1/categories
listOrders(query?)GET /v1/orders
getOrder(id)GET /v1/orders/{id}

The client is deliberately thin: it does not re-validate responses. The contract schemas are its compile-time types, and the server has already validated what it sent.

Creating a listing

const { listing } = await marka.createListing({
  title: "Trek road bike, 56 cm", // 1 to 200 characters
  description: "Light use, new tyres.",
  price: 45000, // integer minor units: CA$450.00
  currency: "CAD",
  categoryPath: "sports/cycling", // lowercase, slash-delimited; never an external category id
  attributes: { frameSizeCm: 56 }, // any JSON value
  madeInCountry: "CA", // ISO 3166-1 alpha-2, or null
  pickupEnabled: true,
  directEnabled: true,
});

await marka.updateListing(listing.id, { price: 42000, madeInCountry: null });

Only title, description and price are required. categoryPath defaults to "general" and must be lowercase letters and digits separated by - or /. madeInCountry is accepted in lowercase and stored uppercase; an unknown code is a 400. On an update, null clears it and omitting it leaves it unchanged. Both methods reject keys they do not know with a 400 that names the key, so a 0.1.0 field name fails loudly rather than being ignored.

updateListing takes title, description, price, imageUrls, madeInCountry and the four pickupEnabled, shippingEnabled, directEnabled, checkoutEnabled flags. It does not take categoryPath or attributes. A listing in the response carries categoryPath, attributes and madeInCountry.

Reading an order

const { orders } = await marka.listOrders({ status: "shipped" });

for (const order of orders) {
  order.listingSnapshot; // the listing as it was when the order was placed
  order.priceSnapshot; // the price breakdown, integer minor units
  const to = order.checkoutShippingDetails; // null, or the typed object below
  if (to) console.log(to.name, to.address.city, to.address.postal_code);
}

checkoutShippingDetails is null or { name, email, phone, address: { line1, line2, city, state, postal_code, country } }, where every field is a string or null. It is the buyer's contact details, so handle it as personal data.

Errors

Failures are thrown, never returned, so a caller who only writes the happy path cannot silently proceed on a failed request. There are two error types:

import { MarkaApiError, MarkaNetworkError } from "@joinmarka/sdk";

try {
  await marka.createListing({ title: "…", description: "…", price: 4500 });
} catch (error) {
  if (error instanceof MarkaApiError) {
    // The API answered and said no. `status` and `code` are what you branch on.
    if (error.status === 402 && error.code === "entitlement_required") {
      console.error("This plan cannot write through the API. Upgrade to Pro.");
    } else if (error.status === 429) {
      console.error("Rate limited — pause for a minute.");
    }
  } else if (error instanceof MarkaNetworkError) {
    // No answer at all: DNS, TLS, timeout, aborted socket. Usually worth retrying.
    // The original failure is preserved on `error.cause`.
  }
}

MarkaApiError.message is the envelope's error string verbatim. MarkaApiError.code is the optional refinement — branch on status first.

One code is raised by the SDK rather than the server: invalid_response, for a 2xx whose body is not JSON at all. It carries the response's own status, because the API did answer — it just did not answer with the contract. In practice this means a proxy, an error page, or a truncated body wearing a 200.

The SDK's local MCP server

@joinmarka/sdk also exports an MCP server that runs in your process, backed entirely by the public HTTP API. It is a different server from the hosted gateway, with a different tool set — and unlike the gateway it is API-key authenticated, because /api/v1 is.

import { createMarkaMcpServer } from "@joinmarka/sdk/mcp";

const server = createMarkaMcpServer({ apiKey: "mk_…" });
await server.startStdio();

The package ships a ready-made bin that does exactly this, marka-mcp-stdio, which runs under plain Node with no loader or install step:

npx -p @joinmarka/sdk marka-mcp-stdio --help

See Claude Desktop for how to wire it into a client.

Its six tools

ToolCalls
search_listingsGET /v1/search
get_listingGET /v1/listings/{id}
list_categoriesGET /v1/categories
get_meGET /v1/me
create_listingPOST /v1/listings
update_listingPATCH /v1/listings/{id}

Every tool calls a MarkaClient method — none of them builds a URL or a header of its own.

Three deliberate absences:

  • No publish_listing. Publishing needs a human in the loop, so neither MCP server exposes it.
  • No order tools. GET /v1/orders exists, but an order carries the buyer's checkout contact details, and handing that to an arbitrary MCP client by default is not a decision this package makes on a seller's behalf. Use MarkaClient.listOrders() directly if you want it.
  • No get_seller_info. The hosted server has one; the public API has no seller endpoint, so this server offers get_me instead — which is what tells an agent whether its key carries api_write and can therefore explain a 402 rather than retrying into it.

create_listing and update_listing go through /api/v1, so they carry the api_write requirement: on a free plan they answer 402.

create_listing takes title, description, price, currency, categoryPath, quantityTotal, pickupEnabled, shippingEnabled, shippingFlatAmount, directEnabled and checkoutEnabled. Its category field is categoryPath, not categoryId; tool inputs are strict, so an agent or saved prompt that still sends categoryId gets a validation error. The tools check their input before calling the API: a title over 200 characters is rejected, and a categoryPath must be lowercase letters and digits separated by - or /. update_listing takes the listing id plus title, description, price and the four enablement flags.

Both this server and the marka-mcp-stdio command need Node.js 22.13 or newer.

Getting a config snippet

For the hosted gateway, reach for getMcpOAuthServerConfig() first — it emits a block with no credential in it at all, because the client authenticates by opening a browser:

import { getMcpOAuthServerConfig } from "@joinmarka/sdk/mcp";

getMcpOAuthServerConfig();
// → { mcpServers: { marka: { type: "http", url: "https://joinmarka.com/api/mcp" } } }

getMcpOAuthServerConfig({ baseUrl: "http://localhost:3001" });

getMcpServerConfig() is the API-key form, and emits ready-to-paste mcpServers blocks for both transports. Use it where there is no browser to open — a script, a CI job — or for the stdio bridge, which is key-authenticated by design:

import { getMcpServerConfig } from "@joinmarka/sdk/mcp";

const config = getMcpServerConfig({
  apiKey: "mk_…",
  // Optional. Defaults to the bare `marka-mcp-stdio` command, which resolves wherever the
  // package is installed. Without an install, use npx; from a checkout, point at the file.
  bridgeCommand: ["npx", "-y", "-p", "@joinmarka/sdk", "marka-mcp-stdio"],
});

config.http;          // → streamable-HTTP block for the hosted gateway
config.claudeDesktop; // → stdio block that spawns this package's local server

Note what each half points at: config.http targets Marka's hosted gateway, and config.claudeDesktop runs the local server. Claude Desktop's config file has no HTTP transport, which is why the two differ — though Claude Desktop can also reach the hosted gateway as a custom connector, which uses OAuth and needs no config file.

getMcpServerConfig's signature is unchanged from 0.1.0; getMcpOAuthServerConfig is new in 0.2.0. The configuration types (MarkaHttpMcpConfig, MarkaStdioMcpConfig, MarkaOAuthMcpConfig, MarkaMcpServerConfig, MarkaMcpConfigOptions, MarkaMcpOptions) are exported from the package root as types only, so importing them does not pull the MCP server into a browser bundle.

From a checkout

Working on the SDK itself rather than using it? Inside the monorepo the package is a workspace dependency that exports TypeScript source directly — no build step, and dist/ is not used by anything internal. The bridge runs through tsx:

node packages/sdk/bin/mcp-stdio.mjs --api-key=mk_… --base-url=http://localhost:3001

The published artifact is a different shape: pnpm release:sdk compiles the package with tsup (bundling the internal @marka/shared contract types into it), assembles a publish-time package.json pointing at dist/, and pushes the result to the marka-node repo, whose GitHub release triggers the npm publish. Bump version in packages/sdk/package.json to cut a release.

On this page