aflink: search AF links

us.aflinkPublished by aflink.usOfficial publisherv1.0.0Updated Nov 8, 2026

Read-only search of aflink.us, a public mirror of the U.S. Air Force portal quick links.

VerifiedStreamable HTTPWeb executableWeb Search & ScrapingKnowledge & Memory

Overview

AI-generated overview

Read-only search of aflink.us, a public mirror of U.S. Air Force portal quick links, letting an assistant look up official and unofficial AF/DoD site URLs by…

What you can do
  • Search the aflink.us link directory by name, acronym, URL, category or purpose, with every query word required to match
  • Get back each link's title, URL and category, with title matches ranked first
  • Distinguish official links from those in the UNOFFICIAL category, which are flagged as not official
  • Optionally cap the number of results returned
When to use it
Useful when an assistant needs the correct URL for an Air Force or DoD website or tool, such as a pay, travel voucher or leave system, and you want a quick lookup rather than a general web search.
Requirements
Remote streamable HTTP endpoint; no authentication, API key or cookies are needed.
Before you install
Results are links only; most official sites still require a CAC or other DoD login that this server does not provide. Links in the UNOFFICIAL category are third-party sites and should be treated with care. The directory is an unofficial mirror, so verify important URLs before relying on them.

Installation

In SourceWeft

  1. Open aflink: search AF links in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Web executable via Streamable HTTP. Remote servers run from the web runtime once configured in a workspace.

Use with an agent

Paste this prompt into Claude Code, Codex, Cursor or another agent — or into SourceWeft chat. The agent reads this server's install guide, shows you its publisher, endpoint and sign-in, and adds it to your client once you agree. Keys never go into a command.

Read https://sourceweft.com/mcp/us.aflink%2Fsearch/install.md and connect the MCP server it describes to my client. Before changing any config, show me its publisher, endpoint and sign-in method and wait for my OK. Never put an API key in a command or in a file I would commit; ask me where it should come from.

Read the install guide the agent follows

Other MCP clients

Add this to your client's mcpServers config.

{
  "mcpServers": {
    "aflink-search-af-links": {
      "type": "http",
      "url": "https://api.aflink.us/mcp"
    }
  }
}

README

Search worker

Cloudflare Worker behind the address-bar autocomplete (OpenSearch suggestions) for aflink.us, and aflink's public, read-only MCP server. Deployed as aflink-autocomplete and served at https://api.aflink.us; the site points at it through suggestedURL in updater.js, which the browser discovers via osdd.xml.

How a suggestion round trips

  1. The browser calls GET /search/{searchTerms} while the user types.
  2. The worker answers ["<term>", ["<title>​ {CATEGORY}", ...]] — the title, a zero-width space, then the category.
  3. Picking one loads aflink.us/?q=<that whole string>.
  4. The page (src/includes/js/search.js) sees the zero-width space, searches on the title, finds the row whose title and category match the suggestion, and follows it.

So a suggestion is only useful if the page can find the same link. Anything the worker offers that the page hides, or matches differently, is a dead suggestion — the user picks it and lands somewhere else, or nowhere.

Staying in sync with the site

Two things keep the two ends together, and both are load-bearing:

  • Matching rules live in src/includes/js/search-match.js. The page inlines that file; this worker imports it. There is one implementation, so normalization, multi-word matching and title/URL/category matching cannot drift. Change matching there and both ends move together.
  • The link list is assembled from src/links/ by scripts/assemble-links.js, which both builds run: updater.js for the site and its links.json, and build-links.js for this worker, which bundles the result in the links.json shape. The worker rebuilds what the page displays from it: official categories in page order (skipping isDeleted links, which the page hides), then the unofficial list.

npm test in the repo root covers both — including checks that the links.json the site build actually produces is what the worker reads, and that the worker's bundle holds the same links.

Deploys

Cloudflare Workers Builds deploys the worker on every push to master that touches one of its watch paths, running cd search_worker && npx wrangler deploy. Wrangler first runs the build command in wrangler.jsonc, node build-links.js, which writes src/links.generated.js (gitignored) from that commit's src/links/. So the deployed worker carries exactly the links the commit lists: nothing is fetched at request time and nothing is cached.

That only works if a link-only change triggers a build, so the watch paths (Workers & Pages → aflink-autocomplete → Settings → Build) must cover everything the worker is built from:

search_worker/*src/links/*src/includes/js/search-match.jsscripts/assemble-links.jsscripts/link-status.js

A new file the worker or build-links.js imports goes on that list too. Retirement is by date (scripts/link-status.js), so a link that retires without any commit leaves the worker at the next link change; the weekly link health commit bounds that to a week.

Changes that need the worker updated

Change on the siteWhat the worker needs
How search matches (normalizing, word splitting, what fields count)Nothing, if the change is made in search-match.js; the worker imports it
A new field or source of links (another section, another JSON file)Add it in assemble-links.js (both builds pick it up); publishedLinks(), build-links.js's slim() and to_entries() must carry it, and a new file needs a watch path
A link becoming hidden on the page (a new flag like isDeleted)to_entries() must filter it out too
Renaming or restructuring links.jsonto_entries() reads the new shape
How the page resolves a chosen suggestion (?q= parsing)The suggestion format here has to match it

MCP server (POST /mcp)

The same worker is a Model Context Protocol server, so an AI agent can look up an Air Force or DoD site the way a person does on aflink.us. It is listed in the MCP Registry as us.aflink/search. The Registry verifies that name against the aflink.us domain itself (see below), and the endpoint is on the same domain, so the listing, the site and the server are visibly one publisher.

  • Endpoint: https://api.aflink.us/mcp (api.aflink.us is a Cloudflare custom domain on this worker, declared in wrangler.jsonc, that also serves the suggestions and submissions), Streamable HTTP transport (MCP spec 2025-11-25; 2025-06-18 and 2025-03-26 clients are also accepted).
  • One tool, search_links: takes a query (and an optional limit, 1–50, default 10) and returns matching links with title, URL, category and summary, title matches first. Links in the UNOFFICIAL category come back with official: false.
  • Read-only: the tool searches the link list bundled into the worker, the same list aflink.us publishes as links.json, and nothing else. It cannot add, change or request links; that still goes through the issue forms and their review.
  • No authentication: it serves only what https://aflink.us/links.json already publishes. No login, no API key, no cookies, and the worker stores nothing about the caller.
  • Same answers as the site: it uses the list and ranking the suggestions use (to_entries() and search() in src/index.js) and the page's own matching rules in search-match.js, so the parity rules above cover it too.

It is stateless: each POST gets one JSON response, no session is kept, and GET /mcp is 405 because the server never sends anything unprompted. Any web origin may call it (CORS *), since there is nothing private to protect; an Origin that is not an http(s) origin is refused, as the transport spec asks. The code is src/mcp.js with no SDK dependency, and test/mcp-server.test.js covers the protocol, the tool and server.json.

Discovery. Besides the Registry listing, the server can be found from the domain itself, as the MCP Server Cards extension (SEP-2127) describes. src/.well-known/ai-catalog.json is served at https://aflink.us/.well-known/ai-catalog.json and lists one entry, which points to the server card at GET https://api.aflink.us/mcp/server-card, the place the spec reserves for a server at /mcp. The card (SERVER_CARD in mcp.js) gives the name, version, description and endpoint, with CORS, an ETag and an hour's caching; it does not list tools, which clients still get from tools/list. It is built in mcp.js rather than read from server.json, so the worker needs no new watch path, and a test checks the two agree. Both formats are new, so update them if the extension changes.

To try it:

sh
claude mcp add --transport http aflink https://api.aflink.us/mcp# or, with the MCP Inspectornpx @modelcontextprotocol/inspector   # transport "Streamable HTTP", the URL above

Publishing to the MCP Registry

server.json in the repository root is the Registry entry. .github/workflows/mcp_publish.yml publishes it, run by hand from the Actions tab:

  1. Merge the change to master. Cloudflare Workers Builds deploys the worker on every push to master that touches search_worker/, and GitHub Pages rebuilds the site; wait for both.
  2. Run Publish MCP server. It runs the MCP tests, checks that https://api.aflink.us/mcp answers initialize as us.aflink/search at the version in server.json and that aflink.us serves the public key, then logs in and publishes.

How the Registry knows aflink.us is ours. The us.aflink/* namespace is proven over HTTP: src/.well-known/mcp-registry-auth holds an Ed25519 public key (v=MCPv1; k=ed25519; p=…), served at https://aflink.us/.well-known/mcp-registry-auth, and the workflow signs its login with the matching private key. Jekyll and the Pages upload skip dot-folders, so src/_config.yml includes it and build.yml uploads hidden files; a test checks both. The private key is the MCP_REGISTRY_KEY secret of the mcp-registry-publish environment, limited to master, because it can publish anything under us.aflink/*. To rotate it, generate a new pair, replace the file and the secret, and let Pages rebuild before publishing.

The custom domain. api.aflink.us was attached once in the dashboard: adding a domain needs zone permission the Workers Builds token does not have, while a deploy that leaves an existing domain as it is needs none. wrangler.jsonc lists it so deploys keep it, and sets workers_dev: true, because a config with routes otherwise turns the workers.dev URL off. The site and getlinks.js now use api.aflink.us; the workers.dev URL stays on only for a month while traffic moves over, and is to be turned off (workers_dev: false) after 2026-12-07.

For a new listing, bump version in server.json and SERVER_VERSION in src/mcp.js together (a test checks they agree, and that the server card matches server.json), merge, let the worker build finish, and run the workflow again. The Registry refuses a version it already has.

Development

sh
npm install          # in this directorynpm run dev          # serves the worker on :8787npm run deploy       # wrangler deploy (by hand; normally Workers Builds deploys on push to master)

A development build of the site (npm run dev:build in the repo root) points its suggestions at localhost:8787, so the two can be exercised together. npm run dev bundles the links from your working copy of src/links/, so a local edit shows up when the worker restarts. npm test in the repo root bundles them too (its pretest step).

Portal link submissions (POST /submit)

The same worker receives link lists from getlinks.js, run in the browser console on any AF Portal page: the script fetches every list the portal has and sends them together as { pages: [{ source, afpCategorizedLinksDto }] }. The worker starts the Portal Sync workflow (.github/workflows/portal_sync.yml), which merges the lists on the portal-sync branch and opens or updates one pull request. Nothing reaches the site until that PR is merged.

The endpoint is public, so src/submit.js is strict:

  • Browser origin must be the AF Portal.
  • Every page's source must be one of KNOWN_SOURCES, each at most once; a new portal list is added there and to PAGES in getlinks.js.
  • Only title, link, contentId and a few known fields are kept, each checked for shape and length; the whole payload is capped at 512 KB.
  • Per-address and daily rate limits, counted in the LINKS KV namespace; an identical payload within a day is acknowledged but not re-sent.
  • The packed payload is kept in KV for a day and served at GET /submission/<id>; the workflow receives only the id, because workflow_dispatch inputs are capped at 64 KB and the lists with their descriptions are larger.
  • On the workflow side, scripts/af-guard.js fails the run (and opens an issue) if a submission would move an existing link to a non-official domain.

It needs one secret, set with wrangler secret put GITHUB_DISPATCH_TOKEN: a fine-grained GitHub token for the aflink repository only, with Actions: read and write and nothing else. That is enough to start a workflow run and not enough to push code. Give it an expiry and rotate it. Without the secret the endpoint answers 503 and getlinks.js falls back to the compressed-data box for the Update AF Links issue form.

Source: search_worker/README.md at commit de35fed

Tools

1

Version history

1
  1. v1.0.0LatestNov 8, 2026

Score

100% of the applicable checks met

How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.

  • Reached in the last 30 daysRequired25 pts
  • Auth declaredRequired10 pts
  • Tools listedRequired15 pts
  • DescriptionRequired15 pts
  • Logo10 pts
  • Documentation15 pts
  • License10 pts