{"slug": "i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the", "title": "I gave an Apify Actor three GitHub tools. It found 16 dependency advisories without touching the code.", "summary": "An Apify Actor built around a GitHub MCP connector successfully identified 16 dependency advisories in a fixture repository without modifying code. The Actor, which can only read manifests, search issues, and write triage issues, updated the same issue on repeated runs, demonstrating idempotent behavior. The integration uses connector-level permissions and a dry-run default to prevent unintended writes.", "body_md": "Most dependency-security demos end with an impressive list and an awkward handoff. Someone still has to find the repository, copy the vulnerable versions, decide where the result belongs, and prevent tomorrow's scan from opening the same ticket again.\n\nI wanted the scan itself to finish that loop, but I did not want an Actor with permission to rewrite a manifest or open an unreviewed pull request.\n\nSo I built an Apify Actor around one deliberately small GitHub MCP connector. It can call exactly three GitHub tools:\n\n`get_file_contents`\n\nto read a dependency manifest;`search_issues`\n\nto find its previous triage issue;`issue_write`\n\nto create or update that issue.The Actor extracts exact dependency versions, calls a separate OSV Actor, and writes a source-linked review queue. It cannot edit a file, create a branch, merge code, or call any other GitHub tool.\n\nOn August 8, 2026, I ran it against a public fixture repository containing three intentionally old npm packages. The run returned 16 advisory rows and created [issue #1](https://github.com/poojitha-rachuri/apify-mcp-connector-fixture/issues/1). I ran the same input again. The second run updated issue #1; the repository still had exactly one issue. After hardening the write guard and untrusted-text handling, I repeated the workflow on published build `1.2.1`\n\n; it updated that same issue again.\n\nThat second run is the result I care about. A useful integration has to survive repetition.\n\nThe naming is easy to mix up, so here is the distinction.\n\nThe [Apify MCP server](https://docs.apify.com/integrations/mcp) exposes Actors to external AI clients such as Codex, Claude, and Cursor. An agent calls into Apify.\n\n[MCP connectors](https://docs.apify.com/integrations/mcp-connectors) let an Actor call an external service on the user's behalf. The Actor calls out to GitHub, Slack, Google Sheets, or another MCP-compatible service.\n\nThis article uses the second direction:\n\nThe connector fires twice in the workflow: first at the data boundary, when the Actor reads `package.json`\n\n, and again at the delivery boundary, when it searches for and writes the triage issue. Without the connector, I would need to copy repository contents into Actor input and move the result back to GitHub manually, or pass a GitHub token into code I did not want handling it.\n\nAn Actor opts into connectors with `resourceType: \"mcpConnector\"`\n\n. I constrained both the upstream server and the tool names:\n\n```\n{\n  \"githubConnector\": {\n    \"title\": \"GitHub MCP connector\",\n    \"description\": \"Read manifests and create or update a triage issue\",\n    \"type\": \"string\",\n    \"resourceType\": \"mcpConnector\",\n    \"mcpServers\": [\n      {\n        \"url\": \"https://api.githubcopilot.com/mcp*\",\n        \"tools\": {\n          \"required\": [\n            \"get_file_contents\",\n            \"search_issues\",\n            \"issue_write\"\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\nThe wildcard covers the official endpoint's trailing slash. It does not accept another hostname.\n\nThe schema is a runtime ceiling. Apify's MCP proxy filters `tools/list`\n\nand rejects calls outside the declared set. Connector-level permissions and the GitHub token's scope still apply underneath it, so the effective permission is the intersection of all three layers.\n\nThe connector credential stays server-side. At runtime the Actor receives a connector ID, the Apify proxy base URL, and its run token. It never receives the GitHub PAT or OAuth token stored in the connector.\n\nDry run is the default. A write run must also provide `confirmWriteTarget`\n\nequal to the exact `owner/repo`\n\n. That is not a GitHub authorization substitute—the connector still enforces the caller's real permissions—but it prevents a casually toggled checkbox from posting to an unintended repository. I use only repositories I own or am explicitly authorized to modify.\n\nThat changes how I assess a Store Actor. I still treat its code as untrusted, because any allowed tool can be misused. But credential exfiltration and unlimited GitHub access are no longer prerequisites for the workflow.\n\nThe runtime code is ordinary Streamable HTTP MCP:\n\n``` python\nimport os\nimport httpx\nfrom mcp import ClientSession\nfrom mcp.client.streamable_http import streamable_http_client\n\nbase_url = os.environ[\"ACTOR_MCP_CONNECTOR_BASE_URL\"].rstrip(\"/\")\nrun_token = os.environ[\"APIFY_TOKEN\"]\n\nasync with httpx.AsyncClient(\n    headers={\"Authorization\": f\"Bearer {run_token}\"}\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            tools = {tool.name for tool in (await session.list_tools()).tools}\n```\n\nI check the returned tool set before reading anything:\n\n```\nrequired = {\"get_file_contents\", \"search_issues\", \"issue_write\"}\nif missing := required - tools:\n    raise RuntimeError(f\"GitHub connector is missing: {sorted(missing)}\")\n```\n\nFailing early is better than reading a repository, paying for an OSV run, and only then discovering that the connector cannot write the result.\n\nThe Actor supports exact npm versions in `package.json`\n\nand `name==version`\n\nentries in Python requirement files. It intentionally skips ranges such as `^1.2.3`\n\n, `~2.0`\n\n, and `requests>=2`\n\n.\n\n```\nEXACT_SEMVER = re.compile(r\"^v?\\d+(?:\\.\\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?$\")\nNPM_PACKAGE = re.compile(\n    r\"^(?:@[A-Za-z0-9][A-Za-z0-9._-]{0,213}/)?\"\n    r\"[A-Za-z0-9][A-Za-z0-9._-]{0,213}$\"\n)\n\ndef parse_package_json(text: str) -> list[str]:\n    data = json.loads(text)\n    packages = []\n\n    for section in (\"dependencies\", \"devDependencies\", \"optionalDependencies\"):\n        for name, raw_version in (data.get(section) or {}).items():\n            version = str(raw_version).strip()\n            if NPM_PACKAGE.fullmatch(str(name)) and EXACT_SEMVER.fullmatch(version):\n                packages.append(f\"npm:{name}@{version.removeprefix('v')}\")\n\n    return packages\n```\n\nOSV can answer a version query only when I give it a version. Guessing what a range resolved to would create a cleaner-looking issue and worse evidence. Lockfile support is the next useful extension; silently treating a range as an installed version is not.\n\nThe GitHub call itself is small:\n\n```\nresponse = await session.call_tool(\n    \"get_file_contents\",\n    arguments={\n        \"owner\": owner,\n        \"repo\": repo,\n        \"path\": \"package.json\",\n    },\n)\n```\n\nOne implementation detail was easy to miss: GitHub returned the file as an embedded MCP resource rather than a plain text block. My first decoder looked only for `content[].text`\n\n, so the Actor reported an empty file even though the tool call had succeeded.\n\nThe corrected decoder prefers resource text:\n\n```\nfor block in result.content or []:\n    resource = getattr(block, \"resource\", None)\n    resource_text = getattr(resource, \"text\", None)\n    if resource_text:\n        resource_texts.append(str(resource_text))\n```\n\nThat bug only appeared against the real connector. MCP standardizes the envelope, but servers can legitimately use different content block types, so a mocked JSON response was not enough.\n\nAfter parsing, the Actor calls my [OSV Vulnerability Scraper](https://apify.com/thirdwatch/osv-vulnerability-scraper) as a child Actor:\n\n```\nrun = await apify_client.actor(\"thirdwatch/osv-vulnerability-scraper\").call(\n    run_input={\n        \"packages\": packages,\n        \"vulnerabilityIds\": [],\n        \"maxResultsPerPackage\": 10,\n    },\n    timeout_secs=300,\n)\n```\n\nKeeping this as a separate Actor gives the OSV lookup its own input/output contract, run ID, retries, and pricing. The GitHub integration owns orchestration and delivery; it does not need to reimplement the vulnerability client.\n\nThe result is still a triage signal, not a verdict. A published advisory does not prove that a vulnerable code path is reachable in this repository. No returned advisory does not prove that the package is safe. The generated issue says both things explicitly and links each row to the upstream source.\n\nThe write path uses a stable title and an invisible marker:\n\n```\nISSUE_TITLE = \"[Dependency risk] OSV triage\"\nISSUE_MARKER = \"<\" + \"!-- thirdwatch-osv-triage --\" + \">\"\n```\n\nBefore calling `issue_write`\n\n, the Actor searches the target repository:\n\n```\nsearch = await session.call_tool(\n    \"search_issues\",\n    arguments={\n        \"query\": (f'repo:{owner}/{repo} is:issue is:open in:title \"{ISSUE_TITLE}\"'),\n        \"owner\": owner,\n        \"repo\": repo,\n        \"perPage\": 5,\n        \"fields\": [\"number\", \"title\", \"html_url\", \"body\"],\n    },\n)\n\nexisting_number = find_actor_owned_issue_number(\n    decode_tool_result(search),\n    marker=ISSUE_MARKER,\n)\n```\n\nThen it selects the write method:\n\n```\narguments = {\n    \"method\": \"update\" if existing_number else \"create\",\n    \"owner\": owner,\n    \"repo\": repo,\n    \"title\": ISSUE_TITLE,\n    \"body\": issue_body,\n}\n\nif existing_number:\n    arguments[\"issue_number\"] = existing_number\n\nawait session.call_tool(\"issue_write\", arguments=arguments)\n```\n\nThe body check matters. A human can independently create an issue with the same title; the Actor must not overwrite it. Only an issue containing the exact invisible marker is Actor-owned.\n\nThe stable issue is a queue, not an immutable audit log. Teams that need history should retain redacted Apify evidence exports or post timestamped comments instead. Sequential scheduled runs update one current issue. Overlapping runs for the same repository are unsupported because GitHub search and issue creation do not form a transaction; I disable schedule overlap rather than claiming concurrency-safe idempotency.\n\nI used a public, fixture-only repository with this manifest:\n\n```\n{\n  \"private\": true,\n  \"dependencies\": {\n    \"axios\": \"0.21.1\",\n    \"lodash\": \"4.17.20\",\n    \"minimist\": \"1.2.5\"\n  }\n}\n```\n\nThe repository contains no application and is explicitly marked non-deployable. Its old versions exist only to make the evidence reproducible.\n\n| Observation | Create proof | Hardened update proof |\n|---|---|---|\n| Actor run | `yB9EAlGsNQAIzAfUo` |\n`aqcd37hKC4cJwHu1e` |\n| Published build | `1.1.1` |\n`1.2.1` |\n| Manifest read | `package.json` |\n`package.json` |\n| Exact versions checked | 3 | 3 |\n| Advisory rows returned | 16 | 16 |\n| GitHub action | Created issue 1 | Updated issue 1 |\n| Parent runtime | 22.5 seconds | 22.7 seconds |\n| Parent platform usage | about $0.00103 | Not exposed by the public run record |\n\nThe OSV child runs were `pQQR830pVur3QojBg`\n\n, `i9YTbjaqUPNNuQAdd`\n\n, and `LO66fiaSKJoHi1MlN`\n\n. After all three parent runs, GitHub still reported one issue—not three. The hardened run's dataset was `MYUPMkpFyGpVlUNLF`\n\n.\n\nThe observed package-to-advisory count can change as OSV publishes or aliases records. That is another reason the issue includes a generation timestamp and child run ID instead of presenting the table as timeless truth.\n\nBefore this build, the awkward choices were:\n\nThe connector removes that glue without making the Actor omnipotent. A user selects an authorized connector at run time. The Actor reads only the requested paths and writes only one review artifact. A schedule can run the same contract tomorrow.\n\nThe manual step it cannot remove is judgment. A maintainer still has to confirm deployment context, read the upstream advisory, choose a compatible fixed version, and test the change. I consider that a feature: the Actor creates a better decision surface without impersonating the decision-maker.\n\nA connector workflow can fail at several independent boundaries. I preserve them separately:\n\n`manifest_errors`\n\n;`NO_EXACT_VERSIONS`\n\n, not a clean security result;`issue_write`\n\nfails when a requested label does not exist;`owner/repo`\n\nis rejected;I also cap manifests and package counts. Repository contents and OSV output are untrusted input. Dependency names and versions pass strict allowlists; table text is escaped and truncated; only valid HTTPS advisory URLs become links. A dependency name, file string, advisory summary, or URL is data to render—not an instruction for the Actor to follow.\n\nGitHub is replaceable here. The useful part is the bounded, repeatable handoff:\n\nThe same pattern fits a release-note monitor, a data-quality report, or a scheduled compliance inventory. The connector fires where authenticated context enters and where the durable artifact leaves. Everything between those points stays testable as ordinary Actor code.\n\nThe connector gave the Actor a safe route from a real repository to a repeatable result in the stack where the team already works.\n\nUse a dedicated connector with the narrowest repository and token scope your workflow supports. Review every advisory before changing production code.\n\n*Disclosure: The Actors described in this article are built and operated by Thirdwatch.*", "url": "https://wpnews.pro/news/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the", "canonical_source": "https://dev.to/apify/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-without-touching-the-403f", "published_at": "2026-09-01 08:21:08+00:00", "updated_at": "2026-09-01 08:55:31.391692+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["Apify", "GitHub", "OSV", "poojitha-rachuri"], "alternates": {"html": "https://wpnews.pro/news/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the", "markdown": "https://wpnews.pro/news/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the.md", "text": "https://wpnews.pro/news/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the.txt", "jsonld": "https://wpnews.pro/news/i-gave-an-apify-actor-three-github-tools-it-found-16-dependency-advisories-the.jsonld"}}