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/sdkThis 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.
| Method | Endpoint |
|---|---|
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 --helpSee Claude Desktop for how to wire it into a client.
Its six tools
| Tool | Calls |
|---|---|
search_listings | GET /v1/search |
get_listing | GET /v1/listings/{id} |
list_categories | GET /v1/categories |
get_me | GET /v1/me |
create_listing | POST /v1/listings |
update_listing | PATCH /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/ordersexists, 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. UseMarkaClient.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 offersget_meinstead — which is what tells an agent whether its key carriesapi_writeand can therefore explain a402rather 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 serverNote 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:3001The 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.