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.
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.
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:
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 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.