I gave an Apify Actor three GitHub tools. It found 16 dependency advisories without touching the code. 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. 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. I 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. So I built an Apify Actor around one deliberately small GitHub MCP connector. It can call exactly three GitHub tools: get file contents to read a dependency manifest; search issues to find its previous triage issue; issue write to 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. On 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 ; it updated that same issue again. That second run is the result I care about. A useful integration has to survive repetition. The naming is easy to mix up, so here is the distinction. The 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. 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. This article uses the second direction: The connector fires twice in the workflow: first at the data boundary, when the Actor reads package.json , 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. An Actor opts into connectors with resourceType: "mcpConnector" . I constrained both the upstream server and the tool names: { "githubConnector": { "title": "GitHub MCP connector", "description": "Read manifests and create or update a triage issue", "type": "string", "resourceType": "mcpConnector", "mcpServers": { "url": "https://api.githubcopilot.com/mcp ", "tools": { "required": "get file contents", "search issues", "issue write" } } } } The wildcard covers the official endpoint's trailing slash. It does not accept another hostname. The schema is a runtime ceiling. Apify's MCP proxy filters tools/list and 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. The 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. Dry run is the default. A write run must also provide confirmWriteTarget equal to the exact owner/repo . 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. That 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. The runtime code is ordinary Streamable HTTP MCP: python import os import httpx from mcp import ClientSession from mcp.client.streamable http import streamable http client base url = os.environ "ACTOR MCP CONNECTOR BASE URL" .rstrip "/" run token = os.environ "APIFY TOKEN" async with httpx.AsyncClient headers={"Authorization": f"Bearer {run token}"} as http client: async with streamable http client f"{base url}/{connector id}", http client=http client, as read, write, : async with ClientSession read, write as session: await session.initialize tools = {tool.name for tool in await session.list tools .tools} I check the returned tool set before reading anything: required = {"get file contents", "search issues", "issue write"} if missing := required - tools: raise RuntimeError f"GitHub connector is missing: {sorted missing }" Failing early is better than reading a repository, paying for an OSV run, and only then discovering that the connector cannot write the result. The Actor supports exact npm versions in package.json and name==version entries in Python requirement files. It intentionally skips ranges such as ^1.2.3 , ~2.0 , and requests =2 . EXACT SEMVER = re.compile r"^v?\d+ ?:\.\d+ {1,3} ?: -+ 0-9A-Za-z.- + ?$" NPM PACKAGE = re.compile r"^ ?:@ A-Za-z0-9 A-Za-z0-9. - {0,213}/ ?" r" A-Za-z0-9 A-Za-z0-9. - {0,213}$" def parse package json text: str - list str : data = json.loads text packages = for section in "dependencies", "devDependencies", "optionalDependencies" : for name, raw version in data.get section or {} .items : version = str raw version .strip if NPM PACKAGE.fullmatch str name and EXACT SEMVER.fullmatch version : packages.append f"npm:{name}@{version.removeprefix 'v' }" return packages OSV 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. The GitHub call itself is small: response = await session.call tool "get file contents", arguments={ "owner": owner, "repo": repo, "path": "package.json", }, One 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 , so the Actor reported an empty file even though the tool call had succeeded. The corrected decoder prefers resource text: for block in result.content or : resource = getattr block, "resource", None resource text = getattr resource, "text", None if resource text: resource texts.append str resource text That 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. After parsing, the Actor calls my OSV Vulnerability Scraper https://apify.com/thirdwatch/osv-vulnerability-scraper as a child Actor: run = await apify client.actor "thirdwatch/osv-vulnerability-scraper" .call run input={ "packages": packages, "vulnerabilityIds": , "maxResultsPerPackage": 10, }, timeout secs=300, Keeping 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. The 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. The write path uses a stable title and an invisible marker: ISSUE TITLE = " Dependency risk OSV triage" ISSUE MARKER = "<" + " -- thirdwatch-osv-triage --" + " " Before calling issue write , the Actor searches the target repository: search = await session.call tool "search issues", arguments={ "query": f'repo:{owner}/{repo} is:issue is:open in:title "{ISSUE TITLE}"' , "owner": owner, "repo": repo, "perPage": 5, "fields": "number", "title", "html url", "body" , }, existing number = find actor owned issue number decode tool result search , marker=ISSUE MARKER, Then it selects the write method: arguments = { "method": "update" if existing number else "create", "owner": owner, "repo": repo, "title": ISSUE TITLE, "body": issue body, } if existing number: arguments "issue number" = existing number await session.call tool "issue write", arguments=arguments The 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. The 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. I used a public, fixture-only repository with this manifest: { "private": true, "dependencies": { "axios": "0.21.1", "lodash": "4.17.20", "minimist": "1.2.5" } } The repository contains no application and is explicitly marked non-deployable. Its old versions exist only to make the evidence reproducible. | Observation | Create proof | Hardened update proof | |---|---|---| | Actor run | yB9EAlGsNQAIzAfUo | aqcd37hKC4cJwHu1e | | Published build | 1.1.1 | 1.2.1 | | Manifest read | package.json | package.json | | Exact versions checked | 3 | 3 | | Advisory rows returned | 16 | 16 | | GitHub action | Created issue 1 | Updated issue 1 | | Parent runtime | 22.5 seconds | 22.7 seconds | | Parent platform usage | about $0.00103 | Not exposed by the public run record | The OSV child runs were pQQR830pVur3QojBg , i9YTbjaqUPNNuQAdd , and LO66fiaSKJoHi1MlN . After all three parent runs, GitHub still reported one issue—not three. The hardened run's dataset was MYUPMkpFyGpVlUNLF . The 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. Before this build, the awkward choices were: The 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. The 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. A connector workflow can fail at several independent boundaries. I preserve them separately: manifest errors ; NO EXACT VERSIONS , not a clean security result; issue write fails when a requested label does not exist; owner/repo is 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. GitHub is replaceable here. The useful part is the bounded, repeatable handoff: The 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. The connector gave the Actor a safe route from a real repository to a repeatable result in the stack where the team already works. Use a dedicated connector with the narrowest repository and token scope your workflow supports. Review every advisory before changing production code. Disclosure: The Actors described in this article are built and operated by Thirdwatch.