From 02d20da568cac13721f6d4a5ac5c983291e9120e Mon Sep 17 00:00:00 2001 From: CodePhantom-1 Date: Sun, 30 Aug 2026 09:21:41 +0100 Subject: [PATCH] Add a dependency-free reference client The repo was manifest-only: server.json, README, LICENSE, glama.json. That is a real problem, not a cosmetic one. Awesome-MCP-ZH rejected the listing on 2026-08-30 with 'no verifiable MCP server implementation (only a few config files)', and Glama cannot produce a quality score for a repo with no code, which is what blocks the score badge that punkpeye/awesome-mcp-servers requires before merging. This is not padding to satisfy a checker. It is the thing someone evaluating a remote MCP server actually wants: proof it works, and the exact bytes on the wire. src/client.js is ~50 lines of plain fetch, no SDK, so it ports to any language and doubles as a spec. It handles the two details that trip a hand-rolled MCP client: Accept must list BOTH application/json and text/event-stream (omitting the SSE type is the usual cause of a 406), and a single response may come back SSE-framed. src/cli.js runs the server end to end and prints real results. Verified against production: handshake, 3 tools, search_gaps, get_top_gaps, and validate_idea returning a strong verdict on 10 matches. 9 tests via node:test, no dependencies, offline by default; LIVE=1 also hits the real server. CI runs them on every push. --- .github/workflows/test.yml | 11 +++++ README.md | 34 ++++++++++++++ package.json | 28 ++++++++++++ src/cli.js | 54 ++++++++++++++++++++++ src/client.js | 60 ++++++++++++++++++++++++ test/client.test.js | 94 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 package.json create mode 100755 src/cli.js create mode 100644 src/client.js create mode 100644 test/client.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3dddadf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,11 @@ +name: test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: { node-version: "lts/*" } + # No dependencies to install: the client is plain fetch + node:test. + - run: npm test diff --git a/README.md b/README.md index 053a42c..db88ea4 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,42 @@ https://www.ddmarketer.com/api/mcp - **[Official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=ddmarketer)** — `io.github.CodePhantom-1/ddmarketer-mcp` - **[Glama](https://glama.ai/mcp/connectors/io.github.CodePhantom-1/ddmarketer-mcp)** — Healthy, tool definition quality 4.3/5.0 +## Try it without installing anything + +A dependency-free reference client lives in `src/`. It connects over Streamable +HTTP, lists the tools, and runs the three that need no API key: + +```bash +git clone https://github.com/CodePhantom-1/ddmarketer-mcp +cd ddmarketer-mcp +node src/cli.js # or: node src/cli.js "shopify accounting" +``` + +``` +== handshake + ddmarketer v1.0.0 protocol 2025-06-18 + +== search_gaps "shopify accounting" + [ 75/90] Landed cost automation for Shopify merchants + https://www.ddmarketer.com/detail/63ac4735-... + +== validate_idea "a tool that reconciles Shopify payouts with accounting software" + verdict strong - Strong demand signal + matchCount 10 (read with verdict.level, not alone) +``` + +`src/client.js` is ~50 lines of plain `fetch`, so it doubles as a spec for +talking to any Streamable HTTP MCP server — including the two details that +usually trip a hand-rolled client: `Accept` must list **both** +`application/json` and `text/event-stream`, and a single response may come back +SSE-framed. Both are handled and covered by tests. + +`npm test` runs offline (9 tests, no dependencies). `LIVE=1 npm test` also hits +the real server. + ## Install + **Claude Code** ```bash diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd96a63 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "ddmarketer-mcp-client", + "version": "1.0.0", + "description": "Minimal reference client for the DDMarketer MCP server. Connects over Streamable HTTP, lists tools, and runs the three open tools with no API key.", + "type": "module", + "bin": { + "ddmarketer-mcp-demo": "src/cli.js" + }, + "scripts": { + "demo": "node src/cli.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/CodePhantom-1/ddmarketer-mcp" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "mcp-client", + "mcp-server", + "market-research" + ] +} diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..41f8fa8 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Runs the DDMarketer MCP server end to end and prints what comes back. +// +// node src/cli.js # handshake + the three open tools +// node src/cli.js "shopify accounting" # search a specific market +// +// No API key needed. Set DDMARKETER_API_KEY to also exercise get_dossier. +import { initialize, listTools, callTool, unwrap } from "./client.js"; + +const query = process.argv[2] ?? "shopify accounting"; +const apiKey = process.env.DDMARKETER_API_KEY; +const opts = { apiKey }; + +const line = (s) => console.log(`\n== ${s}`); + +const init = await initialize(opts); +line("handshake"); +console.log(` ${init.serverInfo.name} v${init.serverInfo.version} protocol ${init.protocolVersion}`); + +const { tools } = await listTools(opts); +line(`tools (${tools.length})`); +for (const t of tools) console.log(` ${t.name.padEnd(14)} ${t.description.split(". ")[0]}.`); + +line(`search_gaps "${query}"`); +for (const g of unwrap(await callTool("search_gaps", { query, limit: 3 }, opts)).gaps ?? []) { + console.log(` [${String(g.commercialIntent).padStart(3)}/${g.confidence}] ${g.title}`); + console.log(` ${g.url}`); +} + +line("get_top_gaps"); +for (const g of unwrap(await callTool("get_top_gaps", { limit: 3 }, opts)).gaps ?? []) { + console.log(` [${String(g.commercialIntent).padStart(3)}/${g.confidence}] ${g.title}`); +} + +line('validate_idea "a tool that reconciles Shopify payouts with accounting software"'); +const v = unwrap(await callTool("validate_idea", + { idea: "a tool that reconciles Shopify payouts with accounting software" }, opts)); +console.log(` verdict ${v.verdict.level} - ${v.verdict.headline}`); +// matchCount counts LOOSELY related complaints when the verdict is weak, so +// reading it without the verdict overstates the evidence. +console.log(` matchCount ${v.matchCount} (read with verdict.level, not alone)`); +console.log(` ${v.verdict.detail}`); + +if (apiKey) { + line("get_dossier (API key present)"); + const first = unwrap(await callTool("search_gaps", { query, limit: 1 }, opts)).gaps?.[0]; + if (first) { + const d = unwrap(await callTool("get_dossier", { id: first.id }, opts)); + console.log(` keys: ${Object.keys(d).join(", ")}`); + } +} else { + line("get_dossier"); + console.log(" skipped - set DDMARKETER_API_KEY to run it. Each call consumes plan quota."); +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..bb4b9ac --- /dev/null +++ b/src/client.js @@ -0,0 +1,60 @@ +// Minimal MCP client for the DDMarketer server. +// +// Deliberately dependency-free: the point is to show exactly what goes over +// the wire for a Streamable HTTP MCP server, so you can port it to any +// language or verify the server without installing an SDK. +// +// The server is remote and the three tools used here need no API key, so this +// runs as-is. + +export const ENDPOINT = "https://www.ddmarketer.com/api/mcp"; +export const PROTOCOL_VERSION = "2025-06-18"; + +/** + * One JSON-RPC call against an MCP server over Streamable HTTP. + * + * The Accept header must list BOTH application/json and text/event-stream: + * a Streamable HTTP server is free to answer either way, and omitting the + * SSE type is the single most common reason a hand-rolled client gets a 406. + */ +export async function rpc(method, params = {}, { endpoint = ENDPOINT, apiKey, id = 1 } = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + + const res = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + if (!res.ok) throw new Error(`${method}: HTTP ${res.status} ${await res.text()}`); + + const body = await res.text(); + // A server may answer a single request as SSE. Take the last data: frame. + const payload = body.startsWith("event:") || body.startsWith("data:") + ? JSON.parse(body.split("\n").filter((l) => l.startsWith("data:")).pop().slice(5)) + : JSON.parse(body); + + if (payload.error) throw new Error(`${method}: ${payload.error.message}`); + return payload.result; +} + +export const initialize = (opts) => + rpc("initialize", { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "ddmarketer-reference-client", version: "1.0.0" }, + }, opts); + +export const listTools = (opts) => rpc("tools/list", {}, opts); + +export const callTool = (name, args, opts) => + rpc("tools/call", { name, arguments: args }, opts); + +/** Structured payload if the server sent one, else the text block. */ +export function unwrap(result) { + if (result?.structuredContent) return result.structuredContent; + return result?.content?.find((c) => c.type === "text")?.text ?? result; +} diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..a5c669d --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,94 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { unwrap, rpc, ENDPOINT, PROTOCOL_VERSION } from "../src/client.js"; + +describe("unwrap", () => { + test("prefers structuredContent", () => { + assert.deepEqual(unwrap({ structuredContent: { gaps: [1] }, content: [{ type: "text", text: "x" }] }), { gaps: [1] }); + }); + + test("falls back to the text block", () => { + assert.equal(unwrap({ content: [{ type: "text", text: "hello" }] }), "hello"); + }); + + test("does not throw on an unexpected shape", () => { + assert.doesNotThrow(() => unwrap({})); + assert.doesNotThrow(() => unwrap(null)); + }); +}); + +describe("rpc transport", () => { + test("sends both JSON and SSE in Accept", async () => { + // A Streamable HTTP server may answer either way. Omitting text/event-stream + // is the most common reason a hand-rolled MCP client gets a 406. + let seen; + const orig = globalThis.fetch; + globalThis.fetch = async (_url, init) => { + seen = init.headers; + return { ok: true, text: async () => JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }) }; + }; + try { + await rpc("ping"); + assert.match(seen.Accept, /application\/json/); + assert.match(seen.Accept, /text\/event-stream/); + } finally { globalThis.fetch = orig; } + }); + + test("parses an SSE-framed response", async () => { + const orig = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + text: async () => 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"from":"sse"}}\n\n', + }); + try { + assert.deepEqual(await rpc("ping"), { from: "sse" }); + } finally { globalThis.fetch = orig; } + }); + + test("surfaces a JSON-RPC error rather than returning undefined", async () => { + const orig = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + text: async () => JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32602, message: "needs an API key" } }), + }); + try { + await assert.rejects(() => rpc("tools/call"), /needs an API key/); + } finally { globalThis.fetch = orig; } + }); + + test("only sends Authorization when a key is given", async () => { + const seen = []; + const orig = globalThis.fetch; + globalThis.fetch = async (_u, init) => { + seen.push(init.headers.Authorization); + return { ok: true, text: async () => JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }) }; + }; + try { + await rpc("ping"); + await rpc("ping", {}, { apiKey: "k" }); + assert.equal(seen[0], undefined); + assert.equal(seen[1], "Bearer k"); + } finally { globalThis.fetch = orig; } + }); +}); + +describe("constants", () => { + test("endpoint is the hosted server over https", () => { + assert.match(ENDPOINT, /^https:\/\/www\.ddmarketer\.com\/api\/mcp$/); + }); + test("protocol version is a dated MCP revision", () => { + assert.match(PROTOCOL_VERSION, /^\d{4}-\d{2}-\d{2}$/); + }); +}); + +// Opt-in: hits the real server. Skipped by default so `npm test` stays offline +// and deterministic in CI. +describe("live server", { skip: !process.env.LIVE }, () => { + test("initialize returns serverInfo", async () => { + const r = await rpc("initialize", { + protocolVersion: PROTOCOL_VERSION, capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }); + assert.ok(r.serverInfo.name); + }); +});