@motrix/mdxp

July 16, 2026 · View on GitHub

npm version license types

English | 简体中文

MDXP (Motrix Download eXchange Protocol) — the JSON-RPC 2.0 wire types, Zod schemas, and bidirectional connection helper that let a browser, CLI, or AI agent hand downloads to a Motrix desktop downloader over any duplex transport.

@motrix/mdxp is the single source of truth for the MDXP wire contract. Both sides of the bridge — the Motrix desktop app (the server, which owns the download engine) and its clients (the browser extension, a CLI, or an agent) — depend on this package so the protocol shape is defined exactly once.

It ships nothing transport-specific: you bring any vscode-jsonrpc MessageReader/MessageWriter pair (stdio, a socket, a WebSocket, a MessagePort) and the library builds a fully typed, bidirectional connection on top of it.

Highlights

  • Schema-first. Every wire shape is a Zod schema; the TypeScript types are z.infer of those schemas, so validation and types can never drift apart.
  • Fully typed connection. sendRequest/onRequest/sendNotification/ onNotification are generic over the method name — params and result types are inferred from that name, with no casts at the call site.
  • Transport-agnostic. Works over anything that implements MessageReader/MessageWriter.
  • Platform RAL entry points. ./node and ./browser install the matching vscode-jsonrpc runtime abstraction layer and re-export its transport classes, so you import everything — connection helper and reader/writer — from one place.
  • One-call constructors. fromWebSocket, fromStdio, and fromWorker build a connection over a common transport in a single call; createMdxpConnection stays the generic escape hatch.
  • Agent-ready. A built-in tool registry emits a JSON-Schema tool catalog you can feed straight into an LLM function-calling API.
  • Forward-compatible. Unknown methods and fields are ignored, not rejected.

Installation

npm install @motrix/mdxp
# or: pnpm add @motrix/mdxp · yarn add @motrix/mdxp

Runtime dependency: vscode-jsonrpc ^9, installed alongside this package. Its transport classes (reader/writer) and the primitives that surface in this package's API — MessageReader, MessageConnection, CancellationToken, CancellationTokenSource, … — are re-exported from @motrix/mdxp (see Entry points), so you rarely need to import vscode-jsonrpc directly. ESM-only; requires Node.js ≥ 18 or a modern bundler.

Entry points

ImportInstalls a RAL?Use it from
@motrix/mdxpNo — platform-agnostic coreShared code, tests, type-only imports
@motrix/mdxp/nodeNode RALA Node host (Electron main, a CLI, a native-messaging host)
@motrix/mdxp/browserBrowser RALA browser host (extension service worker, page)

vscode-jsonrpc v9 requires a runtime abstraction layer (RAL) to be installed before a connection can be created. Importing @motrix/mdxp/node or @motrix/mdxp/browser installs the right one into the same vscode-jsonrpc instance this package uses, and re-exports the entire public API plus that platform's transport classes (StreamMessageReader/Writer for Node, BrowserMessageReader/Writer for the browser) — so a host imports everything, including its reader/writer, from a single place.

Quick start

Node host (over stdio)

import { fromStdio } from '@motrix/mdxp/node'

// Over process.stdin / process.stdout (the native-messaging / CLI case).
const conn = fromStdio()

// Register handlers BEFORE listen().
conn.onNotification('$/task/progress', (p) => {
  const pct = p.bytesTotal ? Math.round((p.bytesDone / p.bytesTotal) * 100) : null
  console.log(`[${p.taskId}] ${p.phase} ${pct ?? '?'}% @ ${p.speedBps} B/s`)
})

conn.listen()

Browser host (over WebSocket)

import { fromWebSocket } from '@motrix/mdxp/browser'

const conn = fromWebSocket(new WebSocket('ws://127.0.0.1:16650/v1'))
conn.onNotification('$/task/progress', (p) => {})
conn.listen()

Convenience constructors

createMdxpConnection(reader, writer) is the generic entry point — bring any vscode-jsonrpc reader/writer. For the common transports, skip the boilerplate:

ConstructorEntryTransport
fromWebSocket(ws)./node · ./browserA browser WebSocket or a Node ws socket
fromStdio(opts?)./nodeprocess.stdin / process.stdout, or given streams
fromWorker(port)./browserA Worker or MessagePort

Each returns a ready MdxpConnection — you still register handlers and call listen(). For any other transport, build the reader/writer and call createMdxpConnection directly.

Core concepts

Server vs. client. The Motrix desktop app is the server — it owns the download engine. A client is whatever drives it: the browser extension, a CLI, or an agent. The connection is symmetric, but methods flow in a defined direction (below).

The handshake comes first. motrix/initialize MUST be the first message of every session. It negotiates the protocol version, exchanges identity, and declares capabilities. Nothing else should be sent until it resolves.

Message direction. Most methods are client→server (the client asks the downloader to do something). Two are server→client — the server asks the client to inspect a page: url/probe and url/resolve (see SERVER_INITIATED_METHODS). Because the server initiates both, a client answers them with onRequest, while the server side calls them with sendRequest.

Usage

Handshake

const hello = await conn.sendRequest('motrix/initialize', {
  protocolVersion: '1.0',
  client: {
    kind: 'cli',              // or 'extension'
    name: 'my-download-agent',
    version: '1.0.0',
    locale: 'en-US',
  },
  capabilities: { submitDownload: true, progress: true, cancellation: true },
  adapters: [],              // page adapters this client can resolve, if any
})

console.log(hello.server.name, hello.server.version)
console.log(hello.capabilities.selectionKinds) // e.g. ['direct', 'hls', 'mux']

Add a download (client → server)

download/add is the public, agent-facing entry point. It accepts a direct URL list, a magnet link, or a base64 torrent, and returns the created task snapshot so you can render it without polling.

// Direct HTTP(S) file
const task = await conn.sendRequest('download/add', {
  kind: 'url',
  saveDir: '/Users/me/Downloads',
  uris: ['https://cdn.example.com/releases/app-1.4.2-arm64.dmg'],
  connections: 8,
})
console.log(task.id, task.status) // "t_01H…", "downloading"

// Magnet link
await conn.sendRequest('download/add', {
  kind: 'magnet',
  saveDir: '/Users/me/Downloads',
  uri: 'magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a',
})

Only http, https, ftp, ftps, and sftp URLs are accepted — the schema rejects file:, data:, and javascript: at the contract boundary, so an agent can never be coerced into a local-file read.

Query and control tasks (client → server)

const { tasks, total } = await conn.sendRequest('task/list', {
  status: 'downloading',
  limit: 20,
})

await conn.sendRequest('task/pause',  { taskId: task.id })
await conn.sendRequest('task/resume', { taskId: task.id })
await conn.sendRequest('task/remove', { taskId: task.id, deleteFiles: false })

Resolve a page (server → client)

The desktop app asks a client whether it can handle a page (url/probe), then asks it to extract the downloadable resources (url/resolve). A client answers by registering handlers:

conn.onRequest('url/probe', async ({ url }) => ({
  handled: /videos\.example\.com/.test(url),
  adapterId: 'example-video',
  confidence: 'high',
}))

conn.onRequest('url/resolve', async ({ url, preferences }) => ({
  selections: [
    {
      kind: 'direct',
      primary: {
        url: 'https://cdn.example.com/v/abc123/1080p.mp4',
        headers: {},
        cookies: [],
        refererPolicy: 'strict-origin-when-cross-origin',
      },
      container: 'mp4',
      quality: preferences?.maxQuality ?? '1080p',
      sizeBytes: 734_003_200,
    },
  ],
  meta: { title: 'Sample clip', author: 'example.com', durationSec: 372 },
  extractedBy: {
    adapterId: 'example-video',
    adapterVersion: '1.0.0',
    extractedAt: Date.now(),
  },
}))

A selection is a discriminated union on kind: direct (one file), hls (a playlist), or mux (separate video + audio streams the server muxes). Each Resource carries the headers/cookies needed to re-fetch it server-side.

Progress and lifecycle (server → client)

conn.onNotification('$/task/progress', (p) => {
  // p.phase: 'queued' | 'downloading' | 'muxing' | 'finalizing'
})
conn.onNotification('$/task/completed', (p) => {
  console.log('done →', p.filePath, `(${p.durationMs} ms)`)
})
conn.onNotification('$/task/error', (p) => {
  console.error(`task ${p.taskId} failed: [${p.code}] ${p.message}`)
})

Cancellation

sendRequest accepts an optional CancellationToken. Cancelling emits $/cancelRequest on the wire (handled by vscode-jsonrpc); a cooperative handler observes token.isCancellationRequested.

import { CancellationTokenSource } from '@motrix/mdxp'

const cts = new CancellationTokenSource()
const pending = conn.sendRequest('url/resolve', { url }, cts.token)
// …the user navigated away:
cts.cancel()

Runtime validation

Every wire shape has a schema. Validate untrusted input at your boundary with safeParse before acting on it:

import { DownloadAddParamsSchema } from '@motrix/mdxp'

const parsed = DownloadAddParamsSchema.safeParse(untrusted)
if (!parsed.success) {
  // parsed.error — a ZodError describing exactly what was wrong
  return
}
await conn.sendRequest('download/add', parsed.data)

Error model

Return structured errors from a handler with makeMdxpError. The code is a JSON-RPC error code; data carries a machine-readable appCode, a retry hint, and free-form context.

import { ErrorCodes, makeMdxpError } from '@motrix/mdxp'

throw makeMdxpError(
  ErrorCodes.ResourceUnavailable,
  'The requested file is no longer available',
  { appCode: 'http.gone', retryable: false, context: { status: 410 } },
)

Classify a received code with isProtocolError(code) (JSON-RPC reserved) or isMotrixError(code) (Motrix's -32001…-32099 range).

AI-agent tool catalog

The agent-facing methods are exposed as a JSON-Schema tool catalog, ready for an LLM function-calling / tool-use API:

import { toAgentToolCatalog } from '@motrix/mdxp'

const tools = toAgentToolCatalog()
// [
//   { name: 'download/add', description, inputSchema: {…JSON Schema}, outputSchema },
//   { name: 'task/list',    … },
//   …
// ]

API reference

Exports

ExportKindPurpose
createMdxpConnection(reader, writer)functionWrap a reader/writer pair in a typed MdxpConnection.
fromWebSocket · fromStdio · fromWorkerfunctionOne-call constructors over a WebSocket / stdio / Worker (from ./node · ./browser).
MdxpConnectiontypeThe connection interface (sendRequest, onRequest, sendNotification, onNotification, dispose, raw).
MdxpRequestMap / MdxpNotificationMaptypeMethod/notification name → params/result type maps.
Methods / NotificationsconstWire-name constants (Methods.DownloadAdd === 'download/add').
ErrorCodesconstJSON-RPC + Motrix-defined error codes.
makeMdxpError(code, msg, data?)functionBuild a structured MdxpError.
isProtocolError / isMotrixErrorfunctionClassify an error code.
ToolsconstRegistry of every client→server method → { description, paramsSchema, resultSchema, agentFacing }.
toAgentToolCatalog()functionThe agentFacing subset as JSON-Schema tools.
SERVER_INITIATED_METHODSconstMethods the server calls on the client (url/probe, url/resolve).
*SchemaZod schemaEvery wire shape, for runtime validation.
MessageReader · MessageWriter · MessageConnection · CancellationToken · CancellationTokenSource · Disposablere-exportvscode-jsonrpc primitives used across the API. Platform transport classes (StreamMessageReader/Writer, BrowserMessageReader/Writer) are re-exported from ./node and ./browser.

Methods

MethodDirectionAgent-facingPurpose
motrix/initializeclient → serverHandshake: version, identity, capabilities.
system/pingclient → serverLiveness probe; echoes sentAt with recvAt.
download/submitclient → serverSubmit a browser-detected, page-shaped download.
download/cancelclient → serverCancel a submitted download by task id.
download/addclient → serverAdd a download by URL(s), magnet, or torrent.
task/listclient → serverList tasks, filterable + paginated.
task/getclient → serverGet one task by id.
task/pause · task/resumeclient → serverPause / resume a task.
task/removeclient → serverRemove a task, optionally deleting files.
stats/getclient → serverAggregate global stats (speeds + counts).
engine/statusclient → serverEngine lifecycle state + feature report.
url/probeserver → clientCan this client's adapters handle a page?
url/resolveserver → clientExtract downloadable resources from a page.

Notifications

NotificationDirectionPayload
motrix/initializedclient → serverHandshake completion (no payload).
$/task/progressserver → clientbytesDone, bytesTotal, speedBps, etaSec, phase.
$/task/completedserver → clientfilePath, durationMs.
$/task/errorserver → clientcode, message.
$/statsserver → clientPeriodic aggregate stats push.
$/pair/revokedserver → clientPairing was revoked (reason).
$/cancelRequesteitherCancellation — handled by vscode-jsonrpc.

Error codes

CodeValueRange
ParseError-32700JSON-RPC reserved
InvalidRequest-32600JSON-RPC reserved
MethodNotFound-32601JSON-RPC reserved
InvalidParams-32602JSON-RPC reserved
InternalError-32603JSON-RPC reserved
RequestCancelled-32800LSP extension
AdapterError-32001Motrix
ResourceUnavailable-32002Motrix
PermissionDenied-32003Motrix
RateLimited-32004Motrix
CapabilityNotSupported-32005Motrix
PairRevoked-32006Motrix

Protocol notes

  • Version. protocolVersion is '1.0'. This is the wire-compatibility version and is independent of this package's npm version.
  • No batching. JSON-RPC batching is forbidden — one frame, one message.
  • Forward-compatible. Result and notification payloads are non-strict: a newer server may add fields that older clients ignore. An unknown method is rejected with MethodNotFound rather than crashing the session.

Design principles

  • Transport-agnostic — the library never assumes a specific transport; any MessageReader/MessageWriter duplex works.
  • Schema-first — define the Zod schema, infer the type; never hand-write a type that has a corresponding schema.
  • Forward-compatible — ignore the unknown rather than reject it.

License

MIT © Dr_rOot