Skip to main content
Glama
monroedean

doctor-of-credit-mcp

by monroedean
README.md
# doctor-of-credit-mcp

People researching bank bonuses, credit-card offers, and other deals published by Doctor of Credit must currently browse and interpret individual articles themselves. The source material contains valuable details: eligibility restrictions, expiration dates, direct-deposit requirements, geographic limits, and update history. But those details are expressed as prose and can change over time. AI clients need a dependable way to discover and retrieve that source material without losing provenance or presenting inferred details as facts.

## Run locally

Requires Node.js 22 or newer.

```sh
npm install
npm run build
npm start
```

The process communicates using MCP over stdio. Configure an MCP client to run
`node /absolute/path/to/doctor-of-credit-mcp/dist/cli.js`.

The server currently provides:

- `list_categories()` retrieves every category from Doctor of Credit's
  WordPress API.
- `get_post(url_or_id)` retrieves a post by its positive integer WordPress ID
  or HTTPS Doctor of Credit article URL.
- `get_post_comments(post_id, page?)` retrieves one chronological page of
  approved public comments for a positive integer WordPress post ID. `page`
  defaults to 1.
- `get_post_comment_manifest(post_id, normalization_version?)` retrieves a
  body-free digest manifest for every comment page. The optional normalization
  version must be `doctor-of-credit-comments-v1`.
- `get_recent_comments(after, before, page?)` retrieves one chronological page
  of approved public comments published inside a required ISO timestamp window.
  `after` must be earlier than `before`, and `page` defaults to 1.
- `get_recent_posts(category?, limit?)` retrieves recent posts from WordPress,
  optionally filtered by a category slug returned by `list_categories`. The
  default limit is 10 and the maximum is 100.
- `search_posts(query, category?, after?, limit?)` searches WordPress article
  text and titles. `category` accepts a slug from `list_categories`, `after`
  accepts an ISO calendar date (`YYYY-MM-DD`) and excludes older publications,
  and `limit` defaults to 10 with a maximum of 100.
- `compare_offers(post_ids[])` retrieves between 1 and 10 unique posts by
  positive integer WordPress ID. It returns each article independently under
  `posts` and does not merge or infer offer terms. If some posts cannot be
  retrieved, successful articles remain available and each failure is reported
  with its requested ID under `failures`. If every retrieval fails, the tool
  returns an actionable MCP error without comparison data.
- `get_big_deals(limit?)` retrieves likely notable deal articles. The default
  limit is 10 and the maximum is 25.
- `find_bank_bonuses(bank?, state?, amount_min?)` retrieves up to 10 likely
  bank-bonus source articles. `bank` is an institution text signal, `state` is
  a two-letter USPS state or District of Columbia code (case-insensitive), and
  `amount_min` is a positive whole-dollar threshold up to $1,000,000.
- `find_credit_card_offers(issuer?, card?, bonus_min?)` retrieves up to 10
  likely credit-card source articles. Issuer and card name are source-text
  signals; `bonus_min` is a positive integer up to 1,000,000 compared within
  each source-mentioned unit.

Post tools return source-backed fields under `source`: the stable ID,
original URL, title, publication and modification timestamps, and cleaned
article text. Interpretive fields live separately under `derived`. The
server sets `derived.outdatedWarning` when the article has not been modified
for more than 180 days. This conservative threshold uses the only dependable
freshness signal available in the source contract—the modification timestamp—
and avoids inferring an offer-specific expiration date from prose. It is a
prompt to verify the source, not a claim that the offer is valid or expired.

### Comment contracts and pagination

Both comment tools use Doctor of Credit's unauthenticated public WordPress
comments endpoint and return fixed pages of at most 100 comments in ascending
publication order. There is no configurable page size and no RSS fallback.
Each comment has this source-backed shape:

```json
{
  "source": {
    "id": 501,
    "postId": 123,
    "parentCommentId": null,
    "authorName": "Public Author",
    "publishedAt": "2026-08-20T14:30:00Z",
    "url": "https://www.doctorofcredit.com/example/#comment-501",
    "commentText": "Readable plain comment text"
  }
}
```

`parentCommentId` is `null` for a root comment and the positive parent comment
ID for a reply. IDs, the public author name, UTC publication timestamp,
permalink, and cleaned comment text all come from the public source. A
successful tool result is `{ comments, pagination }`; `pagination` contains
`page`, fixed `pageSize: 100`, `totalItems`, `totalPages`, and `hasNextPage`.
Text content is the JSON serialization of the same value returned as structured
content. A valid empty first page returns `comments: []`, zero totals, and
`hasNextPage: false` rather than an error.

Callers must keep requesting pages until `hasNextPage` is false. For example,
this complete recent-comment scan keeps one fixed `before` cutoff on every page:

```js
const after = "2026-08-01T00:00:00Z";
const before = "2026-08-08T00:00:00Z"; // Fix once for a repeatable scan.
let page = 1;
const comments = [];

while (true) {
  const result = await client.callTool({
    name: "get_recent_comments",
    arguments: { after, before, page },
  });
  if (result.isError) throw new Error(result.content[0].text);

  const current = result.structuredContent;
  comments.push(...current.comments);
  if (!current.pagination.hasNextPage) break;
  page += 1;
}
```

Keep the same `after` and `before` values for the entire loop; moving the
`before` cutoff while paging can make the scan drift. Public WordPress data does
not expose moderation approval time, so successive recent-comment scans should
use overlapping time windows and deduplicate downstream by the stable
`source.id`. Persistence, retries, page checkpoints, retrieval timestamps,
deduplication, source-snapshot association, evidence classification, and
scouting schedules also belong in downstream clients.

Comment retrieval is strictly read-only and requests only the public `id`,
`post`, `parent`, `author_name`, `date_gmt`, `content`, and `link` fields. It
sends no authentication, cookies, nonces, session data, or write request, and
does not expose email addresses, IP addresses, avatars, moderation metadata, or
other privileged commenter data. Any malformed comment or pagination value
fails the entire page without partial structured content.

### Comment-thread manifests

The optional `doctor-of-credit/thread-manifest` server capability advertises
`get_post_comment_manifest`, manifest schema version `1`, and comment
normalization version `doctor-of-credit-comments-v1`. Clients that do not see
that capability can continue using `get_post_comments` sequentially; the
existing retrieval tools and their responses are unchanged.

`get_post_comment_manifest` reads every fixed-size comment page in order twice
and returns `{ manifest }` only when both complete observations agree, with
this shape:

```json
{
  "postId": "123",
  "schemaVersion": "1",
  "normalizationVersion": "doctor-of-credit-comments-v1",
  "observedAt": "2026-09-03T12:00:00.000Z",
  "pageSize": 100,
  "totalItems": 101,
  "totalPages": 2,
  "pages": [
    { "page": 1, "itemCount": 100, "digest": "<sha256>" },
    { "page": 2, "itemCount": 1, "digest": "<sha256>" }
  ]
}
```

Each lowercase SHA-256 digest hashes the JSON serialization of that page's
ordered comments. Version 1 serializes objects with the fields `upstreamId`,
`parentCommentId`, `authorName`, `publishedAt`, `canonicalUrl`, and `content`,
in that order. IDs are decimal strings; a missing parent is `null`; author and
content use Unicode NFKC plus trimming; timestamps use ISO UTC; URLs use the
standard URL serialization; and CRLF in content is normalized to LF. The
content value is the same cleaned public comment text returned by
`get_post_comments`. Array order is significant.

The tool returns no comment bodies. It uses the same unauthenticated public
GET request and restricted WordPress field list as comment retrieval, and is
advertised as read-only and non-destructive. A malformed comment, missing or
duplicate page membership, changing totals, invalid global order, or
unsupported normalization version fails without partial manifest data. In
that case, consumers should fall back to full sequential comment retrieval.

`get_recent_posts` falls back to the global or category-specific RSS feed when
WordPress is unavailable or returns invalid data. RSS items use the same post
shape, but RSS does not publish a modification timestamp, so `modifiedAt` is
`null` for those results. Their outdated warning conservatively uses the
publication timestamp instead. The server does not persist retrieved content
or metadata.

`search_posts` uses WordPress search directly so relevance and all filters are
applied by the source. It does not fall back to the recent-post RSS feed,
because that feed cannot provide a complete or relevance-ranked search result.

`get_big_deals` applies a deterministic candidate policy to the 100 most recent
posts (using the existing RSS fallback when WordPress is unavailable). An
article qualifies when its cleaned title or text contains a dollar amount of at
least $500 or an amount of at least 50,000 immediately followed by “points” or
“miles.” Candidates are ordered by: number of qualifying signal types, largest
dollar mention, largest points/miles mention, publication date, then stable post
ID, all descending. The matched maxima and qualifying-signal count appear under
`derived.selectionSignals`; they are text-selection signals, not assertions
about an offer's value, availability, or validity. Every candidate otherwise
retains the shared source and warning contract.

`find_bank_bonuses` searches up to 100 WordPress results using the supplied
institution, the state's full name, and “bank bonus” as search terms. It then
keeps only articles whose cleaned title/text contains both “bonus” and a
banking term (`bank`, `banking`, `checking`, `savings`, `credit union`, or
`deposit account`). Optional filters must also appear in the source text: the
institution is matched case-insensitively, geography matches the full state
name or uppercase postal code, and the largest dollar mention must meet
`amount_min`. Results are ordered by publication date and stable ID, descending,
then capped at 10. Match booleans and the largest dollar mention live under
`derived.bankBonusSignals`; they identify candidate evidence only and do not
assert geographic eligibility, direct-deposit requirements, availability,
offer value, or current validity. Search failures return errors rather than
falling back to an incomplete recent-post feed.

`find_credit_card_offers` searches up to 100 WordPress results using the
supplied issuer, card name, and “credit card offer” as search terms. It keeps
only articles whose cleaned title/text contains both a credit-card term and an
offer signal (`bonus`, `offer`, `welcome`, or `sign-up`). Optional issuer and
card filters must also occur in the source text. `bonus_min` matches when either
the largest dollar mention or the largest points/miles mention meets the
threshold in its own source unit; dollars and rewards are never converted or
valued against one another. Results are ordered by publication date and stable
ID, descending, then capped at 10. All match booleans and amount mentions live
under `derived.creditCardOfferSignals`; they are candidate evidence only and do
not assert eligibility, bonus value, restrictions, availability, or current
validity. Search failures return errors rather than incomplete RSS results.

Upstream HTTP, network, and response-validation failures are returned as
actionable MCP tool errors rather than empty or fabricated results.

## Development

```sh
npm run typecheck
npm test
```

Tests exercise behavior through an MCP client. Most use an in-memory MCP
transport with controlled upstream HTTP fixtures; a smoke test launches the
compiled stdio entry point.

## Planned tools

I’d next add higher-level research tools:
Those higher-level tools would search and collect likely articles, but let the connected model interpret restrictions, expiration dates, direct-deposit rules, and similar prose. Results should always include:
Original article URL
Publication and modification dates
Cleaned article text
An explicit distinction between source data and AI-derived fields
Warnings when an offer may be outdated
Internally, it can use the WordPress endpoint first, RSS as a fallback, and a small SQLite cache. No separate public REST service is necessary—the MCP server itself becomes the interface.
A TypeScript implementation using the official MCP SDK would make a clean local server that works over stdio, with optional remote HTTP support later.

TDQS

A4.1/5.0

Scored across 8 tools

Disambiguation4/5

Each tool has a clearly defined role, from general content browsing (list_categories, get_post, get_recent_posts, search_posts) to specialized offer lookups (find_bank_bonuses, find_credit_card_offers). Minor overlap exists between compare_offers and get_post (batch vs single retrieval) and between find_* tools and search_posts, but descriptions are clear enough to guide selection.

Naming Consistency5/5

All tool names follow a consistent snake_case verb_noun pattern (list_categories, get_post, search_posts, compare_offers, find_bank_bonuses, etc.). No camelCase or inconsistent verb styles are present, making the naming highly predictable.

Tool Count5/5

With 8 tools, the server is well-scoped for its purpose as a Doctor of Credit content interface. It covers essential browsing and searching actions without unnecessary bloat, fitting comfortably within the ideal 3-15 tool range.

Completeness5/5

The tool set provides comprehensive coverage for a read-only content API: listing categories, retrieving posts by ID/URL, browsing recent posts, searching, and specialized queries for bank bonuses, credit card offers, and big deals. There are no obvious gaps that would hinder an agent from accessing the available content.

Maintenance

ActivityMaintained
ResponsivenessResponsive