Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
54 changes: 54 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -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.");
}
60 changes: 60 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
@@ -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;
}
94 changes: 94 additions & 0 deletions test/client.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});