<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Fekri]]></title><description><![CDATA[Fekri]]></description><link>https://fekdaoui.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 17:48:37 GMT</lastBuildDate><atom:link href="https://fekdaoui.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Build a small TypeScript MCP server you can fork]]></title><description><![CDATA[An MCP server gives a compatible AI application a defined set of tools and resources. For a first implementation, the useful question is straightforward…]]></description><link>https://fekdaoui.hashnode.dev/build-a-small-typescript-mcp-server-you-can-fork</link><guid isPermaLink="true">https://fekdaoui.hashnode.dev/build-a-small-typescript-mcp-server-you-can-fork</guid><category><![CDATA[mcp]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[AI]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[open source]]></category><dc:creator><![CDATA[Fekri]]></dc:creator><pubDate>Thu, 22 Oct 2026 12:26:33 GMT</pubDate><content:encoded><![CDATA[<p>An MCP server gives a compatible AI application a defined set of tools and resources. For a first implementation, the useful question is straightforward: can a client discover a capability, call it with validated arguments, and understand its response?</p>
<p>The <a href="https://github.com/buildfound/mcp-server-template">TypeScript MCP server template</a> focuses on that path. It uses the official <code>@modelcontextprotocol/sdk</code> 1.x package, Zod, strict TypeScript, ESM, and Node.js 20 or newer. It includes two offline tools, a resource, stdio and Streamable HTTP entrypoints, and tests. You do not need credentials or a running database to try it.</p>
<h2>Start with a small working loop</h2>
<p>Clone the repository and run the checks:</p>
<pre><code class="language-sh">git clone https://github.com/buildfound/mcp-server-template.git
cd mcp-server-template
npm install
npm run typecheck
npm run build
npm test
npm run inspect
</code></pre>
<p>The last command opens MCP Inspector against the compiled stdio entrypoint. List the tools, call <code>add</code> with <code>{"a":2,"b":3}</code>, and read <code>info://server</code>. This checks the same discovery and execution path that a client uses.</p>
<p>There are three boundaries to understand. Pure functions live under <code>src/tools/</code>. The <code>createServer()</code> factory in <code>src/server.ts</code> registers protocol capabilities. The entrypoints choose how messages reach that server. Keeping those boundaries visible makes it easier to replace the examples without changing transport code.</p>
<h2>Register a tool with McpServer and Zod</h2>
<p>The addition function in <code>src/tools/add.ts</code> is deliberately ordinary:</p>
<pre><code class="language-ts">export function add(a: number, b: number): number {
  return a + b;
}
</code></pre>
<p>Inside <code>createServer()</code>, its registration looks like this:</p>
<pre><code class="language-ts">server.registerTool(
  "add",
  {
    description: "Add two numbers.",
    inputSchema: { a: z.number(), b: z.number() },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  ({ a, b }) =&gt; ({ content: [{ type: "text", text: String(add(a, b)) }] }),
);
</code></pre>
<p>The module imports <code>McpServer</code> from <code>@modelcontextprotocol/sdk/server/mcp.js</code> and <code>z</code> from <code>zod</code>. The SDK exposes the input schema to clients and validates tool arguments before invoking the handler. A caller passing a string for <code>a</code> receives a tool error.</p>
<p>The handler adapts a number into an MCP text response. Business logic does not need to know about content blocks, transports, or clients. Start new tools the same way: write a function, then give it a name, description, schema, and response adapter.</p>
<h2>Return structured results for useful work</h2>
<p>The second tool, <code>word_stats</code>, counts words and characters and returns the most frequent words. Try <code>{"text":"Hello hello world!","top":1}</code>. You get three words, eighteen characters, and <code>hello</code> with a count of two.</p>
<p>Its optional <code>top</code> argument defaults to five and accepts integers from one through twenty. The input text has a size limit. These constraints describe the function's useful operating range and keep accidental requests bounded.</p>
<p>The output schema in <code>src/server.ts</code> is:</p>
<pre><code class="language-ts">outputSchema: {
  wordCount: z.number().int().nonnegative(),
  characterCount: z.number().int().nonnegative(),
  topWords: z.array(z.object({ word: z.string(), count: z.number().int().positive() })),
},
</code></pre>
<p>The registered handler returns both representations:</p>
<pre><code class="language-ts">({ text, top }) =&gt; {
  const result = wordStats(text, top);
  return {
    content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
    structuredContent: { ...result },
  };
},
</code></pre>
<p>Clients can consume the structured object while still displaying readable text. The output schema gives that object a contract the SDK can validate.</p>
<p>Text counting needs explicit rules. Here, words are runs of Unicode letters and numbers, normalized to NFC and lowercased. Punctuation separates words. Frequency ties use JavaScript string order. Character count measures code points in the original input, including whitespace; it does not count visible grapheme clusters. Documenting those choices makes results interpretable.</p>
<h2>Expose a resource</h2>
<p>Resources give clients readable content at a URI. The template registers <code>info://server</code> with an <code>application/json</code> MIME type. Its callback returns this content block:</p>
<pre><code class="language-ts">contents: [{
  uri: uri.href,
  mimeType: "application/json",
  text: JSON.stringify({ name, version, tools: ["add", "word_stats"] }, null, 2),
}],
</code></pre>
<p>The name and version come from <code>package.json</code>. The tools array describes the examples. When replacing them, update this array too. The URI is a protocol identifier; reading it does not make an HTTP request to a website.</p>
<h2>Choose a transport</h2>
<p>Use stdio when a local client launches your server as a child process. Run <code>npm start</code> after building, or <code>npm run dev</code> while editing. <code>src/stdio.ts</code> connects the factory to <code>StdioServerTransport</code>. Stdout carries MCP messages, so use <code>console.error</code> for diagnostics.</p>
<p>Use Streamable HTTP when the server should run separately from the client:</p>
<pre><code class="language-sh">npm run start:http
curl http://127.0.0.1:3000/health
</code></pre>
<p>Clients connect to <code>http://127.0.0.1:3000/mcp</code>. <code>HOST</code> and <code>PORT</code> configure the listener. Each POST creates a fresh server and transport, with JSON responses and no session ID. This stateless choice avoids session storage but provides no persistent notification stream or resumability. GET and DELETE on <code>/mcp</code> return 405.</p>
<p>The default listener binds to loopback. Host validation helps address DNS rebinding, and requests with a cross-origin Origin header are rejected. Authentication is not included. Before network deployment, add access controls, TLS, request limits, and a trusted host/origin policy appropriate to your proxy.</p>
<h2>Connect Claude</h2>
<p>For Claude Desktop, open Settings, then Developer, then Edit Config. Merge this into <code>claude_desktop_config.json</code> and restart the application:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "template": {
      "command": "node",
      "args": ["/absolute/path/mcp-server-template/dist/stdio.js"]
    }
  }
}
</code></pre>
<p>Replace the path with your compiled checkout. If the application cannot locate Node, give <code>command</code> its absolute executable path. The <a href="https://modelcontextprotocol.io/docs/develop/connect-local-servers">local server guide</a> explains the configuration file.</p>
<p>For Claude Code, choose either command from its <a href="https://code.claude.com/docs/en/mcp">MCP setup documentation</a>:</p>
<pre><code class="language-sh">claude mcp add --transport stdio template -- node /absolute/path/mcp-server-template/dist/stdio.js
claude mcp add --transport http template-http http://127.0.0.1:3000/mcp
</code></pre>
<p>Start the HTTP server separately for the second command. Check the connection with <code>/mcp</code> inside Claude Code.</p>
<h2>Test through the protocol</h2>
<p>Unit tests cover the functions, while <code>test/server.test.ts</code> connects a real SDK client without sockets:</p>
<pre><code class="language-ts">const server = createServer();
const client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
t.after(async () =&gt; { await client.close(); await server.close(); });
await server.connect(serverTransport);
await client.connect(clientTransport);
</code></pre>
<p>This excerpt runs inside a <code>node:test</code> callback. The test then lists tools and resources, calls both tools, checks structured output, reads the resource, and exercises invalid inputs. Another integration test uses an ephemeral HTTP port and <code>StreamableHTTPClientTransport</code>. Together they check registration and transport behavior beyond the pure calculations.</p>
<h2>Make it your own</h2>
<p>Fork the <a href="https://github.com/buildfound/mcp-server-template">repository</a>, rename the package and bin, replace the example functions, and update resource metadata. Run <code>npm install</code> after renaming to refresh the lockfile, then typecheck, build, and test. Keep the MIT notice. Rebuild before restarting clients that point to <code>dist/stdio.js</code>.</p>
<p>Built by <a href="https://buildfound.com">BuildFound</a>, a founder-led AI product studio.</p>
]]></content:encoded></item><item><title><![CDATA[App Store Competitor Mapping in Node.js: Keyword Overlap, Reciprocal Rank and a CSV, and the Walls You'll Hit (2026)]]></title><description><![CDATA[Find out who you really compete with in iOS search, using one dependency-free script]]></description><link>https://fekdaoui.hashnode.dev/app-store-competitor-mapping-in-node-js-keyword-overlap-reciprocal-rank-and-a-csv-and-the-walls-you-ll-hit-2026</link><guid isPermaLink="true">https://fekdaoui.hashnode.dev/app-store-competitor-mapping-in-node-js-keyword-overlap-reciprocal-rank-and-a-csv-and-the-walls-you-ll-hit-2026</guid><category><![CDATA[Node.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Fekri]]></dc:creator><pubDate>Mon, 19 Oct 2026 09:25:14 GMT</pubDate><content:encoded><![CDATA[<p>Ask an indie iOS developer who their competitors are and you'll usually get the two or three apps they admire. Ask App Store search and you often get a different list: the apps that keep turning up next to yours across the phrases your users actually type.</p>
<p>This post builds a small Node.js script that finds that second list. You give it a handful of keywords. It pulls the top results for each, works out which apps keep appearing and how high, and writes a CSV you can sort in any spreadsheet. No dependencies, no API key, about 100 lines.</p>
<p>Then we go through the walls you hit when you try to trust the output.</p>
<p>Everything here was run on Node 20 against the live iTunes Search API while writing.</p>
<h2>The idea in one paragraph</h2>
<p>For each keyword, take the top N apps (default 10). For each app, record its position per keyword. Then score each app two ways:</p>
<ul>
<li><strong>Coverage:</strong> the share of your keywords where it's in the top N. "How often is this app in my way?"</li>
<li><strong>Weighted score:</strong> the mean of <code>1 / position</code> across your keywords (reciprocal rank). A #1 counts as 1.0, a #10 as 0.1, and an absence as 0. "How <em>much</em> is it in my way?"</li>
</ul>
<p>Coverage alone overrates apps that sit at #9 everywhere. Reciprocal rank alone overrates an app that owns one keyword. Looking at both side by side is what makes the table useful.</p>
<h2>The script</h2>
<p>Save as <code>overlap.mjs</code>:</p>
<pre><code class="language-javascript">// Who actually competes with you in App Store search? Node 20+, no dependencies.
// Usage: node overlap.mjs "calorie tracker" "calorie counter" "macro tracker" ...
import { writeFile } from "node:fs/promises";

const COUNTRY = process.env.COUNTRY ?? "us";
const TOP_N = Number(process.env.TOP_N ?? 10);
const DELAY_MS = 3500; // ~17 requests/min, below Apple's ~20/min guidance

const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));

async function search(term, attempt = 0) {
  const url = new URL("https://itunes.apple.com/search");
  url.search = new URLSearchParams({
    term, country: COUNTRY, entity: "software", limit: "50",
  });
  const res = await fetch(url, {
    headers: { "User-Agent": "overlap-script/0.1 (you@example.com)" },
    signal: AbortSignal.timeout(20_000),
  });
  if ([403, 429, 500, 502, 503].includes(res.status) &amp;&amp; attempt &lt; 4) {
    const wait = 2 ** attempt * 10_000 + Math.random() * 5_000;
    console.error(`  ${res.status} on "${term}", retrying in ${Math.round(wait / 1000)}s`);
    await sleep(wait);
    return search(term, attempt + 1);
  }
  if (!res.ok) throw new Error(`${res.status} for "${term}"`);
  // Content-Type is often text/javascript, so don't rely on res.json() sniffing.
  const body = JSON.parse(await res.text());
  return body.results.slice(0, TOP_N);
}

const keywords = process.argv.slice(2);
if (keywords.length &lt; 2) {
  console.error('Give me at least two keywords, e.g. node overlap.mjs "habit tracker" "daily planner"');
  process.exit(1);
}

// appId -&gt; { name, seller, ratings, hits: Map&lt;keyword, position&gt; }
const apps = new Map();

for (const [i, kw] of keywords.entries()) {
  if (i &gt; 0) await sleep(DELAY_MS);
  const results = await search(kw);
  results.forEach((r, idx) =&gt; {
    const app = apps.get(r.trackId) ?? {
      name: r.trackName, seller: r.sellerName,
      ratings: r.userRatingCount ?? 0, hits: new Map(),
    };
    app.hits.set(kw, idx + 1);
    apps.set(r.trackId, app);
  });
  console.error(`fetched "${kw}" (${results.length} results)`);
}

// Score: share of keywords where the app is top-N, plus a position-weighted
// score so #1 counts more than #10 (reciprocal rank, normalised to 0-1).
const rows = [...apps.entries()].map(([id, a]) =&gt; {
  const rr = [...a.hits.values()].reduce((s, p) =&gt; s + 1 / p, 0);
  return {
    id, name: a.name, seller: a.seller, ratings: a.ratings,
    coverage: a.hits.size / keywords.length,
    weighted: rr / keywords.length,
    positions: keywords.map((k) =&gt; a.hits.get(k) ?? ""),
  };
}).sort((x, y) =&gt; y.weighted - x.weighted || y.coverage - x.coverage);

console.log(`\nTop competitors across ${keywords.length} keywords (${COUNTRY}, top ${TOP_N}):\n`);
console.table(rows.slice(0, 12).map((r) =&gt; ({
  app: r.name.slice(0, 32),
  coverage: `${Math.round(r.coverage * 100)}%`,
  weighted: r.weighted.toFixed(2),
  ratings: r.ratings,
})));

const esc = (v) =&gt; `"${String(v).replaceAll('"', '""')}"`;
const csv = [
  ["app_id", "name", "seller", "ratings", "coverage", "weighted", ...keywords].map(esc).join(","),
  ...rows.map((r) =&gt; [r.id, r.name, r.seller, r.ratings,
    r.coverage.toFixed(2), r.weighted.toFixed(3), ...r.positions].map(esc).join(",")),
].join("\n");
await writeFile(`overlap-${COUNTRY}.csv`, csv + "\n");
console.log(`\nWrote overlap-${COUNTRY}.csv (${rows.length} apps)`);
</code></pre>
<p>Notes on the choices:</p>
<ul>
<li><strong>Sequential, with a fixed delay.</strong> <code>Promise.all</code> over 30 keywords is the fastest way to get your IP throttled (see wall 1). A plain <code>forâ€¦of</code> with <code>await sleep()</code> is boring and correct.</li>
<li><strong><code>AbortSignal.timeout</code></strong> is built into Node 18+, so you don't need a timeout library.</li>
<li><strong><code>JSON.parse(await res.text())</code>:</strong> the API sends <code>content-type: text/javascript</code>, so parse the text yourself instead of trusting a content-type check.</li>
<li><strong>Keyed on <code>trackId</code>.</strong> Names change. Ids don't.</li>
</ul>
<h2>Running it</h2>
<p>Say you're building a planner app:</p>
<pre><code class="language-bash">node overlap.mjs "habit tracker" "daily planner" "routine planner" "to do list" "adhd planner"
</code></pre>
<p>Output from my run (US storefront, trimmed):</p>
<pre><code>Top competitors across 5 keywords (us, top 10):

app                              coverage  weighted  ratings
'Structured: Daily Planner Todo' '80%'     '0.65'    167714
'Habit Tracker'                  '80%'     '0.37'    148017
'To Do List MinimaList &amp; Widget' '60%'     '0.24'    48420
'Daily Planner, To-Do List'      '60%'     '0.18'    11583
'Routine Planner, Habit Tracker' '60%'     '0.16'    18074
'Finch: Self-Care Pet'           '60%'     '0.13'    764903
'Todoist: To Do List &amp; Calendar' '20%'     '0.10'    129909

Wrote overlap-us.csv (34 apps)
</code></pre>
<p>That table already holds a few non-obvious things. Finch, a self-care pet app, shows up in 60% of these "planner" searches, which no planner developer would have listed as a rival. Todoist, the famous one, appears in only one of the five. Open <code>overlap-us.csv</code> and the per-keyword position columns show <em>where</em> each rival is strong. That's the start of a keyword strategy: phrases where the top apps have few ratings and no rival covers everything are worth a closer look.</p>
<p>Run it for another storefront with <code>COUNTRY=gb node overlap.mjs ...</code>, or widen the net with <code>TOP_N=25</code>.</p>
<h2>Wall 1: Rate limits</h2>
<p>Apple describes the Search API limit as roughly 20 calls per minute, subject to change, and answers excess traffic with <code>403</code>s. The script waits 3.5 s between calls and backs off exponentially on <code>403</code>/<code>429</code>/<code>5xx</code>. That makes 40 keywords take about 2.5 minutes. Fine for a script, wrong for an HTTP handler. If you put this behind a UI, run it as a background job and cache the results by day.</p>
<h2>Wall 2: The API is not the App Store search box</h2>
<p>The iTunes Search API searches the catalogue. Apple doesn't promise its ordering matches what someone sees in the App Store app, where an ad slot, editorial content and other undocumented factors can come into play. Treat the positions as <strong>a consistent relative signal</strong>: good for "who keeps showing up", weaker for "we're exactly #4". Check a few keywords by hand on a device before you make decisions from them.</p>
<h2>Wall 3: Your keyword list is the biggest bias in the output</h2>
<p>The matrix only knows the keywords you fed it. Pick five keywords that describe <em>your</em> positioning and you'll mostly find apps that copy your positioning. To get a fairer picture:</p>
<ul>
<li>Mix head terms ("to do list") with long-tail phrases ("adhd planner").</li>
<li>Include a few rival brand names. People search for those, and the results show who sits next to the leader.</li>
<li>Use 20 to 40 keywords, not 5. With 5, one keyword is 20% of the coverage score.</li>
</ul>
<h2>Wall 4: One developer, many apps</h2>
<p>Studios often ship several near-identical apps, and each takes its own row. The CSV includes <code>seller</code> so you can group by developer in a pivot table. A seller with four apps at 20% coverage each may matter more than a single app at 60%.</p>
<h2>Wall 5: Ratings are not size</h2>
<p>It's tempting to sort by <code>userRatingCount</code> and call that market share. Don't. Rating counts build up over an app's whole life, depend on how hard an app prompts for reviews, and say nothing about this month. An old app with 700k ratings can be coasting while a newer one with 2k ratings grows fast. Use ratings as a rough "how established is page one" signal, nothing more.</p>
<h2>Wall 6: Scraping, bot protection and terms</h2>
<p>When the Search API's ceilings annoy you (200 results max, one storefront per call), you'll be tempted to scrape the App Store web pages or the private endpoints the App Store app uses. Those are undocumented, protected against bots and liable to change overnight, and using them may break Apple's terms. Read the Search API terms as well before shipping anything commercial. Build on the public endpoint and accept its limits.</p>
<h2>What search overlap can't tell you</h2>
<p>This script answers "who do I keep running into in search?" It says nothing about <strong>how those competitors get users outside search</strong>: which ads they keep paying for, and which posts about them took off.</p>
<p>For that part I don't write scripts. I use <a href="https://peekpanda.com">PeekPanda*</a>. It keeps dated public paid-ad observations for iOS apps, millions of ads in total, so you can see which creatives a rival has kept running for months, next to the searches it's been observed in. An ad nobody switches off for months is usually the one worth studying. I still run this overlap script first, because it's free, quick and shows me which apps are worth looking up.</p>
<h2>Wrap-up</h2>
<ul>
<li>About 100 lines of dependency-free Node turns a keyword list into a ranked competitor map plus a CSV.</li>
<li>Score with both coverage and reciprocal rank, key on <code>trackId</code>, and group by seller.</li>
<li>Respect the rate limit, treat positions as relative, and remember the output is only as good as your keyword list.</li>
</ul>
<p>Run it on your own niche. The app you least expected near the top of the table is usually the most useful thing it tells you.</p>
<p>*I build it.</p>
]]></content:encoded></item></channel></rss>