I gave an Apify Actor three GitHub tools. It found 16 dependency advisories without touching the code.
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. 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. MCP connectors point in the opposite direction from the Apify MCP server The naming is easy to mix up, so here is the distinction. The Apify MCP server exposes Actors to external AI clients such as Codex, Claude, and Cursor. An agent calls into Apify. 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. Declare the capability ceiling in the Actor input schema 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. Connect through the proxy with a standard MCP client The runtime code is ordinary Streamable HTTP MCP: 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. Read manifests, but query only versions I can defend 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. Keep vulnerability lookup separate from repository access After parsing, the Actor calls my 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. Search before writing The write path uses a stable title and an invisible marker: ISSUE_TITLE = "[Dependency risk] OSV triage" ISSUE_MARKER = " " 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. Three production runs, one issue 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 ,
Comments
No comments yet. Start the discussion.