diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7493879..88619dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Build run: npm run build + - name: Test + run: npm test + - name: MCP introspection smoke test run: | echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \ diff --git a/README.md b/README.md index ac0c55f..8304ca4 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `p | `linkedin` | Browser fallback | returns `needs_browser` + compose URL | | `medium` | Browser fallback | returns `needs_browser` + compose URL | | `twitter` / `x` | Browser fallback | returns `needs_browser` + compose URL | +| `twitter_getxapi` / `getxapi_twitter` | API (write) | `GETXAPI_API_KEY`, `GETXAPI_AUTH_TOKEN`, and `GETXAPI_ENABLE_ACTIONS=true` | + +GetXAPI uses an API key for the service and an X account `auth_token` cookie in `GETXAPI_AUTH_TOKEN`. Keep both in the distribution profile at runtime. Writes require explicit opt-in. The adapter rejects empty or over-280-character text instead of silently truncating it, and reports a live URL only after a successful response includes a tweet ID. See the [Create Tweet API documentation](https://docs.getxapi.com/docs/tweets/create-tweet). ## Example agent call @@ -185,7 +188,7 @@ Agent (Claude Code / n8n / Cursor / any MCP host) ▼ content-distribution-mcp (this package, stdio transport) │ no LLM calls — pure I/O - ├── adapters/ devto · hashnode · github-discussions · reddit · bluesky · browser + ├── adapters/ devto · hashnode · github-discussions · reddit · bluesky · getxapi-twitter · browser └── backends/ yaml (post log · profiles · schedule queue · subreddit catalog) ``` diff --git a/package-lock.json b/package-lock.json index 1deed45..47bd52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@automatelab/content-distribution-mcp", - "version": "2.2.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@automatelab/content-distribution-mcp", - "version": "2.2.0", + "version": "3.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "js-yaml": "^4.1.0", + "@modelcontextprotocol/sdk": "^1.32.1", + "js-yaml": "^4.3.2", "zod": "^3.23.0" }, "bin": { @@ -27,24 +27,24 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -148,20 +148,20 @@ "license": "Python-2.0" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -171,6 +171,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -354,9 +367,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -469,9 +482,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -594,9 +607,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -606,9 +619,9 @@ } }, "node_modules/hono": { - "version": "4.12.21", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", - "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -657,9 +670,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -696,9 +709,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -869,9 +892,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -879,15 +902,20 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1024,14 +1052,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, diff --git a/package.json b/package.json index 751c352..b8d9aff 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ ], "scripts": { "build": "tsc", - "prepare": "npm run build" + "prepare": "npm run build", + "pretest": "npm run build", + "test": "node --test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.0", - "js-yaml": "^4.1.0", + "@modelcontextprotocol/sdk": "^1.32.1", + "js-yaml": "^4.3.2", "zod": "^3.23.0" }, "devDependencies": { diff --git a/src/adapters/getxapi-twitter.ts b/src/adapters/getxapi-twitter.ts new file mode 100644 index 0000000..b7c6339 --- /dev/null +++ b/src/adapters/getxapi-twitter.ts @@ -0,0 +1,71 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; + +const DEFAULT_GETXAPI_BASE_URL = "https://api.getxapi.com"; + +export class GetXAPITwitterAdapter { + hints(): ChannelHints { + return { + max_length: 280, + supported_md_features: ["links"], + cta_placement: "bottom", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const apiKey = profile.credentials.GETXAPI_API_KEY; + if (!apiKey) { + return { channel: variant.channel, state: "failed", error: "GETXAPI_API_KEY required in profile" }; + } + + const enableActions = profile.credentials.GETXAPI_ENABLE_ACTIONS === "true"; + if (!enableActions) { + return { channel: variant.channel, state: "failed", error: "GETXAPI_ENABLE_ACTIONS must be true to publish writes" }; + } + + const authToken = profile.credentials.GETXAPI_AUTH_TOKEN; + if (!authToken) { + return { channel: variant.channel, state: "failed", error: "GETXAPI_AUTH_TOKEN required in profile" }; + } + if (!variant.body.trim() || Array.from(variant.body).length > 280) { + return { channel: variant.channel, state: "failed", error: "GetXAPI tweet text must contain 1 to 280 characters; shorten the variant before publishing" }; + } + + const baseUrl = (profile.credentials.GETXAPI_BASE_URL || DEFAULT_GETXAPI_BASE_URL).replace(/\/+$/, ""); + const text = variant.body; + + const res = await fetch(`${baseUrl}/twitter/tweet/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ auth_token: authToken, text }), + }); + + if (!res.ok) { + // Remote error bodies can echo account credentials. Keep them out of logs. + return { channel: variant.channel, state: "failed", error: `GetXAPI publish failed: HTTP ${res.status}` }; + } + + const data = await res.json() as { status?: string; data?: { id?: string } }; + const tweetId = data?.data?.id; + if (data?.status !== "success" || typeof tweetId !== "string" || !/^\d+$/.test(tweetId)) { + return { channel: variant.channel, state: "failed", error: "GetXAPI did not confirm a created tweet ID" }; + } + const liveUrl = `https://x.com/i/web/status/${tweetId}`; + + return { + channel: variant.channel, + state: "live", + live_url: liveUrl, + published_at: new Date().toISOString(), + }; + } + + async unpublish(_liveUrl: string, _profile: Profile): Promise<[boolean, string]> { + return [false, "GetXAPI tweet deletion not yet implemented"]; + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e0bb1b5..85089f7 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -3,6 +3,7 @@ import { HashnodeAdapter } from "./hashnode.js"; import { GitHubDiscussionsAdapter } from "./github-discussions.js"; import { RedditAdapter } from "./reddit.js"; import { BlueskyAdapter } from "./bluesky.js"; +import { GetXAPITwitterAdapter } from "./getxapi-twitter.js"; import { makeBrowserAdapter } from "./browser.js"; import type { Variant, PublishResult, ChannelHints } from "../models.js"; import type { Profile } from "../backends/base.js"; @@ -35,5 +36,9 @@ export function buildAdapterMap(): Record { twitter_browser: twitter, "twitter-browser": twitter, x: twitter, + twitter_getxapi: new GetXAPITwitterAdapter(), + "twitter-getxapi": new GetXAPITwitterAdapter(), + getxapi_twitter: new GetXAPITwitterAdapter(), + "getxapi-twitter": new GetXAPITwitterAdapter(), }; } diff --git a/test/getxapi-twitter.test.mjs b/test/getxapi-twitter.test.mjs new file mode 100644 index 0000000..890eb44 --- /dev/null +++ b/test/getxapi-twitter.test.mjs @@ -0,0 +1,63 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { GetXAPITwitterAdapter } from "../dist/adapters/getxapi-twitter.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); +const variant = { channel: "twitter_getxapi:main", title: "", body: "Hello world", tags: [], extras: {} }; +const profile = { name: "client", credentials: { GETXAPI_API_KEY: "test-api-key", GETXAPI_AUTH_TOKEN: "test-account-token", GETXAPI_ENABLE_ACTIONS: "true" } }; +const adapter = new GetXAPITwitterAdapter(); + +test("sends both credentials and reads the documented nested tweet ID", async () => { + globalThis.fetch = async (url, options) => { + assert.equal(url, "https://api.getxapi.com/twitter/tweet/create"); + assert.equal(options.headers.Authorization, "Bearer test-api-key"); + assert.deepEqual(JSON.parse(options.body), { auth_token: "test-account-token", text: "Hello world" }); + return new Response(JSON.stringify({ status: "success", data: { id: "1234567890" } })); + }; + const result = await adapter.publish(variant, profile); + assert.equal(result.state, "live"); + assert.equal(result.live_url, "https://x.com/i/web/status/1234567890"); +}); + +test("missing credentials or write opt-in prevents any request", async () => { + globalThis.fetch = async () => { assert.fail("must not send a request"); }; + for (const key of ["GETXAPI_API_KEY", "GETXAPI_AUTH_TOKEN", "GETXAPI_ENABLE_ACTIONS"]) { + const credentials = { ...profile.credentials }; + delete credentials[key]; + assert.equal((await adapter.publish(variant, { ...profile, credentials })).state, "failed"); + } +}); + +test("rejects empty and oversized text without truncating or posting", async () => { + globalThis.fetch = async () => { assert.fail("must not send a request"); }; + for (const body of [" ", "x".repeat(281)]) { + assert.equal((await adapter.publish({ ...variant, body }, profile)).state, "failed"); + } +}); + +test("preserves complete Unicode text at the character limit", async () => { + const body = "😀".repeat(280); + globalThis.fetch = async (_url, options) => { + assert.equal(JSON.parse(options.body).text, body); + return new Response(JSON.stringify({ status: "success", data: { id: "123" } })); + }; + assert.equal((await adapter.publish({ ...variant, body }, profile)).state, "live"); +}); + +test("does not report unconfirmed responses as live", async () => { + for (const data of [{}, { status: "error", msg: "failed" }, { status: "success", data: {} }, { status: "success", data: { id: "invalid" } }]) { + globalThis.fetch = async () => new Response(JSON.stringify(data)); + const result = await adapter.publish(variant, profile); + assert.equal(result.state, "failed"); + assert.equal(result.live_url, undefined); + } +}); + +test("does not copy credentials echoed in error responses into logs", async () => { + globalThis.fetch = async () => new Response("test-api-key test-account-token", { status: 401 }); + const result = await adapter.publish(variant, profile); + assert.equal(result.state, "failed"); + assert.match(result.error, /401/); + assert.doesNotMatch(result.error, /test-api-key|test-account-token/); +});