{"slug": "i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the", "title": "I let an Apify Actor read my docs and write one GitHub issue. It found a 404 without cloning the repo.", "summary": "A developer built a GitHub Documentation Link Auditor as an Apify Actor that reads Markdown files from a repository and checks their links for 404s, using an MCP connector to avoid cloning the repo or storing GitHub tokens. The Actor creates or updates a single GitHub issue with the results, and in tests it correctly identified a 404 and updated the issue on subsequent runs.", "body_md": "A broken-link checker usually starts from a deployed website. Mine needed to start one step earlier, in Markdown that lives in GitHub.\n\nThat distinction mattered. Some of the files were not deployed yet. I wanted exact `path:line`\n\nevidence, not the URL of a rendered page. I also wanted a scheduled run to leave the result where maintainers already work, without cloning the repository into the Actor or putting a GitHub token in Actor input.\n\nSo I built a [GitHub Documentation Link Auditor](https://apify.com/thirdwatch/github-doc-link-auditor) around an Apify MCP connector. The Actor reads explicit `.md`\n\nor `.mdx`\n\npaths, checks at most 100 public HTTP links, and creates or updates one marker-owned GitHub issue. It cannot edit a file, create a branch, or open a pull request.\n\nOn August 9, 2026, I ran the published build twice against a public fixture pinned to one commit. Both runs checked four links: two were reachable, one returned 404, and one loopback URL was rejected before a request. The first run created [issue #5](https://github.com/poojitha-rachuri/apify-mcp-connector-fixture/issues/5). The second updated issue #5.\n\nThe update is as important as the 404. A daily checker that opens a daily issue is just another kind of broken workflow.\n\n[MCP connectors](https://docs.apify.com/integrations/mcp-connectors) let an Actor call a third-party service during its run. That is the opposite direction from the [Apify MCP server](https://docs.apify.com/integrations/mcp), which lets an external AI client call Actors as tools.\n\nThis workflow uses the connector at two narrow boundaries:\n\nThe first call replaces a checkout or manual upload. The last two calls replace copying a report back into GitHub and checking whether yesterday's issue already exists. Link extraction, DNS policy, HTTP checks, classification, and report rendering happen inside the Actor.\n\nI use two connector inputs because dry runs should not require write authority. A user may select the same GitHub connector for both fields, but the Actor's contract does not demand issue tools until writing is enabled.\n\nAn MCP connector is declared with `resourceType: \"mcpConnector\"`\n\n. The read input exposes one GitHub tool:\n\n```\n{\n  \"githubReadConnector\": {\n    \"title\": \"GitHub read connector\",\n    \"type\": \"string\",\n    \"resourceType\": \"mcpConnector\",\n    \"mcpServers\": [\n      {\n        \"url\": \"https://api.githubcopilot.com/mcp*\",\n        \"tools\": { \"required\": [\"get_file_contents\"] }\n      }\n    ]\n  }\n}\n```\n\nThe optional write input declares only issue search and issue write:\n\n```\n{\n  \"githubWriteConnector\": {\n    \"title\": \"GitHub issue connector\",\n    \"type\": \"string\",\n    \"resourceType\": \"mcpConnector\",\n    \"mcpServers\": [\n      {\n        \"url\": \"https://api.githubcopilot.com/mcp*\",\n        \"tools\": {\n          \"required\": [\"search_issues\", \"issue_write\"]\n        }\n      }\n    ]\n  }\n}\n```\n\nNo content-write, branch, pull-request, or merge tool is available. The connector's own GitHub authorization still applies underneath this ceiling.\n\nThe Actor receives a connector ID and an Apify proxy URL, not the GitHub credential. It connects with the run token through a standard Streamable HTTP MCP client:\n\n```\nbase_url = os.environ[\"ACTOR_MCP_CONNECTOR_BASE_URL\"].rstrip(\"/\")\ntoken = os.environ[\"APIFY_TOKEN\"]\n\nasync with httpx.AsyncClient(\n    headers={\"Authorization\": f\"Bearer {token}\"},\n    trust_env=False,\n) as http_client:\n    async with streamable_http_client(\n        f\"{base_url}/{connector_id}\",\n        http_client=http_client,\n    ) as (read, write, _):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n```\n\nBefore making a call, I inspect `tools/list`\n\n. A read session must expose `get_file_contents`\n\n; a write session must expose both issue tools. Failing there is cheaper and clearer than discovering a permission problem after 100 HTTP checks.\n\nThe input names specific Markdown paths rather than asking the Actor to wander through a repository:\n\n```\n{\n  \"owner\": \"poojitha-rachuri\",\n  \"repo\": \"apify-mcp-connector-fixture\",\n  \"ref\": \"f7e56bcddba23cea5232fbe7735b5065ecc01ed3\",\n  \"markdownPaths\": [\"docs/link-audit-fixture.md\"],\n  \"maxLinks\": 20,\n  \"dryRun\": false,\n  \"confirmWriteTarget\": \"poojitha-rachuri/apify-mcp-connector-fixture\"\n}\n```\n\nA full 40-character commit makes the run reproducible. Branches and tags work, but the output marks them as mutable.\n\nThe connector call is small:\n\n```\nresponse = await session.call_tool(\n    \"get_file_contents\",\n    arguments={\n        \"owner\": owner,\n        \"repo\": repo,\n        \"path\": path,\n        \"ref\": ref,\n    },\n)\n```\n\nGitHub MCP can return file data as structured content, text, or an embedded resource. My decoder checks all three and does not let an empty `{}`\n\nin `structuredContent`\n\nhide a valid resource block. That edge case came from running a different GitHub connector workflow against the real service; I reused the lesson here.\n\nEach file is capped at 1 MB. I skip fenced code, inline code, and HTML comments, then retain every source line for each distinct URL. Extraction also has a per-file match ceiling. If that ceiling or `maxLinks`\n\nis reached, the run says `PARTIAL`\n\n; it never presents a prefix as a complete audit.\n\nA Markdown file can contain URLs chosen by anyone with repository write access. Fetching them blindly would turn a link checker into an SSRF tool.\n\nBefore each request, including every redirect, the Actor rejects:\n\n`localhost`\n\n, `.local`\n\n, and `.internal`\n\nnames;A preflight DNS check is not sufficient. A hostile hostname could return a public address during validation and a private address when the HTTP client resolves it again. I use an `aiohttp`\n\nresolver that connects only to the exact public address set already validated for that hostname. The original hostname remains available for the HTTP `Host`\n\nheader, TLS SNI, and certificate verification.\n\n```\naddresses, error = await resolve_public_addresses(hostname, 5)\nif error:\n    return classify_resolution_error(error)\n\nresolver.pin(hostname, addresses)\nasync with client.get(\n    current_url,\n    headers={\"Range\": \"bytes=0-1023\"},\n    allow_redirects=False,\n) as response:\n    status = response.status\n```\n\nEnvironment proxies are disabled. Redirects are followed manually so each new hostname passes the same validation. The Actor uses GET with a small range instead of HEAD because plenty of healthy servers implement HEAD differently from normal navigation.\n\nThe deliberate fixture URL `http://127.0.0.1:8080/admin`\n\nnever reaches `client.get`\n\n. It becomes `unsafe_target`\n\nwith the reason `non-standard ports are rejected`\n\n. A standard-port loopback target would be rejected by the address check.\n\nI use four result states:\n\n| State | Meaning |\n|---|---|\n`reachable` |\nA 2xx response was observed |\n`observed_not_found` |\nA 404 or 410 was observed and needs human confirmation |\n`needs_review` |\nAuth, rate limiting, DNS, timeout, malformed redirect, or server failure made the result inconclusive |\n`unsafe_target` |\nThe target was rejected before a request |\n\nOne 404 does not prove that a URL is permanently dead. Geo routing, a CDN rule, or bot protection can make the Actor see something a maintainer does not. The issue therefore says “HTTP 404/410 observed,” includes the timestamp and source line, and asks a human to open the source file before changing it.\n\nA run also reports its own completeness:\n\n`COMPLETE`\n\nwhen every requested file was read and every discovered link fit within the limits;`PARTIAL`\n\nwhen a file, extraction, or link limit prevented a complete result;`FAILED`\n\nwhen no requested file could be read.A failed zero-file audit is pushed without the paid event. It cannot return `NO_NOT_FOUND_OBSERVED`\n\nor charge for a clean result it never established.\n\nA fixed issue title is not enough. A README audit and a release-docs audit in the same repository should not overwrite each other.\n\nThe Actor hashes the repository, ref, and normalized path set into a 12-character scope key:\n\n```\ncanonical = \"\\n\".join(\n    (owner.casefold(), repo.casefold(), ref or \"default\", *sorted(set(paths)))\n)\nscope_key = hashlib.sha256(canonical.encode()).hexdigest()[:12]\nmarker = issue_marker(scope_key)\n```\n\nIt searches open issues with the base title, then updates only an issue whose body contains the exact scope marker. A human-written issue with a similar title is left alone.\n\nCreate responses from `issue_write`\n\ndo not always contain an issue number. I do not label such a write verified on faith. The Actor searches again for the exact title and marker, with a bounded retry for GitHub's search-index delay. If it still cannot read the issue back, the dataset preserves the completed link audit but reports `issue_action: write_failed`\n\n.\n\nDry run is the default. A real write also requires `confirmWriteTarget`\n\nto match the exact `owner/repo`\n\n. That confirmation is not authorization—the connector decides what the user may access—but it catches a surprising number of copy-paste mistakes.\n\nThe first network smoke labeled `https://apify.com/`\n\ninconclusive even though the page was healthy. The response carried a Content Security Policy header larger than aiohttp's default 8 KB field limit. The client failed while parsing headers, before I saw the 200.\n\nI raised the field allowance to a bounded 32 KB:\n\n```\nasync with aiohttp.ClientSession(\n    connector=connector,\n    timeout=timeout,\n    trust_env=False,\n    max_field_size=32_768,\n) as client:\n    ...\n```\n\nThe second failure was subtler. The GitHub connector created the issue, but its `issue_write`\n\nresponse omitted the issue number. My strict write check called that unverified. The next run found and updated the issue, proving the side effect had happened.\n\nThat led to the marker-bound read-back described above. It also changed the output contract: link evidence is saved even when issue delivery fails, and `issue_error`\n\nexplains the failed stage. A transient write problem should not erase a completed audit.\n\nBoth defects passed mocked happy-path tests. The fixture run earned its keep before the article had a headline.\n\nThe public fixture contains two normal links, one stable missing path at `example.com`\n\n, and one unsafe loopback target. The final build was `1.0.4`\n\n.\n\n| Observation | Create run | Repeat run |\n|---|---|---|\n| Actor run | `NQdWlaw0v0yYU2Kbh` |\n`nSZwPk7XhSTD29HBH` |\n| Dataset | `cAjWlHWSC2ZGLMfno` |\n`GzfWfFJJKYS5bbM0f` |\n| Immutable ref | `f7e56b…01ed3` |\n`f7e56b…01ed3` |\n| Links checked | 4 | 4 |\n| Reachable | 2 | 2 |\n| 404/410 observed | 1 | 1 |\n| Unsafe targets rejected | 1 | 1 |\n| Audit status | `COMPLETE` |\n`COMPLETE` |\n| GitHub action | Created issue 5 | Updated issue 5 |\n| Runtime | 16.1 seconds | 5.5 seconds |\n| Platform usage | $0.000734 | $0.000276 |\n\nThe create run took longer because it waited for marker-bound read-back after GitHub accepted the write. Both costs were far below one cent, with no browser or proxy.\n\nThe output contains the resolved public addresses, final URL, status, redirect count, duration, observation timestamp, and every source location. It does not include connector credentials.\n\nWithout the connector, I had three unattractive choices: clone the repository with a credential, ask someone to upload Markdown and copy the report back, or maintain another automation service just for GitHub glue.\n\nThe connector removed that glue. A schedule can read the same authorized source at a pinned ref, run a bounded audit, and refresh one review surface. The Actor never possesses a general-purpose GitHub token and never gains a code-mutation tool.\n\nIt did not remove judgment. A maintainer still decides whether the link is intended, whether the Actor saw the same response a reader sees, and what replacement belongs in the docs. Nor is this a site crawler: if the job is to discover every page on a deployed domain, a normal broken-link crawler is the better tool.\n\nThat boundary is the point. MCP connectors are most useful when they give an Actor the smallest missing piece of context or delivery authority. Here, three GitHub calls turned a standalone checker into a repeatable maintenance workflow without turning it into a repository administrator.\n\n*Disclosure: The Actors described in this article are built and operated by Thirdwatch.*", "url": "https://wpnews.pro/news/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the", "canonical_source": "https://dev.to/apify/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-without-cloning-the-4l66", "published_at": "2026-09-01 08:21:41+00:00", "updated_at": "2026-09-01 08:55:20.787940+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Apify", "GitHub", "MCP connector", "GitHub Documentation Link Auditor"], "alternates": {"html": "https://wpnews.pro/news/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the", "markdown": "https://wpnews.pro/news/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the.md", "text": "https://wpnews.pro/news/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the.txt", "jsonld": "https://wpnews.pro/news/i-let-an-apify-actor-read-my-docs-and-write-one-github-issue-it-found-a-404-the.jsonld"}}