I gave my Actor a GitHub MCP connector. It could see 44 tools and use 4.
I publish 22 audit Actors on Apify. They all answer one question: is this public record still true?
The registry serves a package with no warning on it. Its repository has been archived. My Actors
find that gap and write it into a dataset.
Then nothing happens. A dataset is a place findings go to be correct in private.
So I built the missing half. It reads an audit dataset, decides what deserves attention, and opens
a GitHub issue for each finding.
It reaches GitHub through an
Apify MCP connector. That is a Model Context
Protocol (MCP) server, wired into the Actor's input by the platform. My Actor never touches my
token.
I expected the interesting part to be the writing. It was not. The interesting part was how little
the platform let my Actor do, and how much the connector appeared to offer.
At authorization, Console listed 44 tools. My run could see four. Both numbers are correct,
and the gap between them is the whole design.
Here is what I built, where in the run the connector fires, and the four things that surprised me.
One of them broke an assumption the Actor was designed around.
What I built, and why it is a separate Actor
The obvious move was to add issue-filing to
github-repository-audit directly. I did not,
for a boring reason: that Actor was under an unrelated measurement, and changing its build would have
destroyed the data.
The boring reason turned out to be the right architecture. Filing issues has nothing to do with
auditing. It needs a dataset, a tracker, and a policy. It does not need to know what npm is.
So dataset-to-github-issues takes a dataset ID and a connector, and nothing about my audit is
hard-coded into it.
The input schema is the interesting file:
{
"datasetId": {
"title": "Audit dataset",
"type": "string",
"editor": "resourcePicker",
"resourceType": "dataset",
"resourcePermissions": ["READ"]
},
"githubConnector": {
"title": "GitHub connector",
"type": "string",
"resourceType": "mcpConnector",
"mcpServers": [
{
"url": "https://api.githubcopilot.com/mcp/",
"tools": {
"required": ["issue_read", "issue_write", "list_issues", "search_issues"]
}
}
]
}
}
Two declarations sit side by side. One names what the run may read. The other names what it may call
through the connector. The platform enforces both. My code enforces neither.
That symmetry is easy to miss when you are writing it. It matters later.
Setting up the connector: the docs and Console disagree
The MCP connectors documentation says:
Supported services include Notion, Slack, GitHub, Sentry, and Supabase.
The Add new MCP connector dropdown in Console offers three presets. I scrolled to be sure. The
list does not grow.
| Preset | URL |
|---|---|
| Sentry | https://mcp.sentry.dev/mcp |
| Notion | https://mcp.notion.com/mcp |
| Linear | https://mcp.linear.app/sse |
Slack, GitHub, and Supabase are not presets. Linear is a preset and is not in that sentence.
This is not a bug. The URL field accepts free text, so the presets are a convenience, not an
allowlist. I typed the URL of
GitHub's own MCP server and waited.
https://api.githubcopilot.com/mcp/
About four seconds later the field turned green and an auth panel appeared. Apify had gone and asked
the server what it supports. It offered API key, selected by default. It rendered OAuth
grayed out, with this text:
This server doesn't support dynamic client registration. Your own OAuth client is recommended.
I like that the platform probes rather than assumes. It also told me exactly which path to take on a
free account: a personal access token used as a bearer credential.
The token I actually issued
I used a fine-grained personal access token,
scoped as tightly as GitHub allows:
| Scope element | Value |
|---|---|
| Repository access | Only select repositories → one repo |
| Permissions | Issues: read and write |
| Permissions | Metadata: read-only (added automatically, marked Required) |
| Expiry | 30 days |
No contents write. No administration. No second repository. Remember this table; it is the point of
the next section.
The trap: the URL reverted while I was not looking
Between filling in the dialog and pasting the API key, the server URL changed under me. It went back
to https://mcp.sentry.dev/mcp, the first preset in the list. The field label also changed from
"MCP server URL" to "MCP server".
My API key field kept its contents. The URL showed a green validation check. Save was enabled.
If I had saved that, a GitHub token would have been registered as the bearer credential for
Sentry's MCP server. Nothing in the dialog would have complained.
Re-typing the GitHub URL and picking it from the suggestion fixed it, and the key survived the
change.
I have a rule now: read the server URL again immediately before you save. Typing it once is not
enough. A connector dialog holds a live credential, and it deserves the same paranoia as a payment
form.
44 tools discovered, 4 of them visible
Saving the connector produced an ID and a list. Console showed the tools it had discovered at
authorization time. All 44:
add_comment_to_pending_review add_issue_comment add_reply_to_pull_request_comment
create_branch create_or_update_file create_pull_request create_repository delete_file
fork_repository get_commit get_file_contents get_label get_latest_release get_me
get_release_by_tag get_tag get_team_members get_teams issue_read issue_write
list_branches list_commits list_issue_fields list_issue_types list_issues
list_pull_requests list_releases list_repository_collaborators list_tags
merge_pull_request pull_request_read pull_request_review_write push_files
request_copilot_review run_secret_scanning search_code search_commits search_issues
search_pull_requests search_repositories search_users sub_issue_write
update_pull_request update_pull_request_branch
Read that list against my token's permissions. create_repository is there. delete_file is there.
merge_pull_request, push_files, fork_repository, run_secret_scanning — all there, all far
outside a token that can only read and write issues in one repository.
The discovered tool list describes the server, not the token. It is a menu, not a grant. Every
one of those calls would die upstream, at GitHub itself.
That is a fine outer boundary. It is also the worst possible place to learn about it. So I looked at
what my run actually got. From the run log:
INFO Read 12 row(s) from dataset iBbLEMf3NUSJcU0cZ.
INFO The proxy exposes 4 tool(s) to this run: issue_read, issue_write, list_issues, search_issues
Four. Exactly the four names in mcpServers[0].tools.required, and nothing else. I changed nothing
about the connector between those two observations. The
input schema declaration
is what narrowed it.
The docs put it in one sentence, and now I have watched it happen:
The proxy enforces that an Actor can only call tools it explicitly declared in its input schema.
So there are three layers, and they do different jobs:
- The token. What the upstream service will honor. Enforced by GitHub, discovered on failure.
- The connector. What server this credential belongs to. Chosen once, in Console.
- The input schema. What this Actor may call. Enforced by the proxy, before the request leaves Apify.
Layer 3 is the one that ties a tool to a specific Actor. It is also the only one a reader of my Actor
can see. Anyone can open my input schema and learn the worst thing my code can do to their GitHub
account.
I value that as much as the enforcement itself. A limit nobody can inspect is a promise.
Where the connector fires, and why not at the end
The easy design writes at the end. Audit, decide, file, done.
I fire the connector in the middle, twice, and the first call is a read. The Actor lists the open
issues in the tracker before it decides anything:
// This is the whole reason the connector fires here and not at the end: the decision of
// whether to open an issue depends on what is already open.
const openKeys = new Set();
for (let page = 1; page <= 5; page += 1) {
const res = await call(client, readTool.name, argsFor(readTool, {
method: methodValue(readTool, /list/),
owner,
repo,
state: 'open',
perPage: 100,
page,
}));
if (!res.ok) { readError = res.error; break; }
const list = Array.isArray(res.data) ? res.data : (res.data?.items ?? res.data?.issues ?? []);
if (!Array.isArray(list) || list.length === 0) break;
for (const issue of list) {
// a title like "[dep-drift] npm:left-pad - deprecated..." gives back "npm:left-pad"
const key = keyFromTitle(issue?.title ?? '');
if (key) openKeys.add(key);
}
if (list.length < 100) break;
}
Identity lives in the issue title. Every issue my Actor opens is tagged [dep-drift], and the
finding key follows the tag. A later run recovers the key by parsing titles it wrote itself. No
state file, no external store, nothing to fall out of sync.
I proved it by running the same input twice, live, with the same settings.
First run:
INFO 0 finding(s) already have an open issue (read 0 title(s) over 1 page(s)).
INFO Opened 3 issue(s).
Second run, identical input:
INFO 3 finding(s) already have an open issue (read 3 title(s) over 1 page(s)).
INFO Opened 3 issue(s).
INFO Done. skipped: 7, filed: 3, deferred: 2.
The Actor recognized its own three issues from the first run and skipped them. It filed the next
three. Two more hit the per-run ceiling, and it reported them as deferred instead of dropping
them.
Every row says why, in the dataset, whether or not it became an issue:
This is what a connector buys that a separate script does not. A write-only integration would
have re-filed the same three issues. Then it would do it again every scheduled run, and the tracker
becomes noise inside a week. Reading and writing in the same run, through the same authorization,
is what makes re-running safe.
The Actor also refuses to write blind. If the read fails, it stops:
if (readError) {
await Actor.fail(
`Reading the existing issues failed: ${readError}. Stopping before any write, because `
+ 'without that list this run cannot tell a new finding from one it filed last time.',
);
}
Duplicates in someone's tracker cost more than a failed run.
The assumption that broke
My audit answers in three states. It found a defect. It found nothing. Or it could not check —
usually because GitHub's hourly allowance ran out mid-run.
The third state is the one I care about. Filing it turns "I don't know" into an alarm. Dropping it
silently turns an unanswered question into a clean bill of health.
So decide() gives it its own outcome:
export function decide(row, { minRiskLevel = 'high', openKeys = new Set() } = {}) {
const key = findingKey(row);
const real = realCodes(row); // codes that mean "we found something"
const unverified = unverifiedCodes(row); // codes that mean "the check did not run"
if (real.length === 0) {
if (unverified.length) {
return { decision: 'withheld', reason: `not verified (${unverified.join(', ')})`, key };
}
return { decision: 'skipped', reason: 'nothing to report', key };
}
if (openKeys.has(key)) {
return { decision: 'skipped', reason: 'an open issue already covers this', key };
}
return { decision: 'file', reason: `${row.riskLevel}: ${real.join(', ')}`, key };
}
To exercise that path honestly, I needed an audit that really ran out of allowance. So I pointed the
audit Actor at gatsby's package.json — 166 dependencies. It duly ran out:
{
"github": { "requestsMade": 56, "distinctRepositories": 131,
"repositoriesNotChecked": 75, "rateLimited": true },
"findings": { "not_checked": 76, "repository_not_on_github": 76, "repo_moved": 7,
"deprecated_on_registry": 4, "repo_archived": 2, "silent_abandonment": 1 }
}
Seventy-six unverified rows. I expected a dry run to report dozens of withheld findings. It reported:
INFO Done. skipped: 161, would-file: 5.
0 finding(s) were withheld because the audit could not verify them
Zero.
I read the rows instead of guessing at the cause. Every single not_checked row also carried
repository_not_on_github. Not one row in 166 had an unverified code and nothing else.
That kills the assumption. I had built the decision around "a finding is either established or
unestablished". Real data says a row is usually both: something the audit established, plus a
caveat about a check that did not run. The whole-row case turns out to be the rare one.
The behavior that actually mattered was the other one. The caveat has to travel with the finding,
into the issue body. Here is issue #11, read back from the public GitHub API:
**npm:string-similarity** — risk: `high`
### What the audit found
- `deprecated_on_registry` (high) — The registry marks this deprecated: "Package no longer supported..."
- `repository_not_on_github` (low) — The registry links to git://github.com/aceakash/string-similarity.git,
which is not a GitHub repository.
### What the audit could not check
- `not_checked` — aceakash/string-similarity was not checked: GitHub's hourly allowance ran out.
These are open questions, not clean results. Re-run the audit to close them.
A reader cannot mistake that last section for a clean result. The design goal held. The code path I
built to demonstrate it never fired.
I am leaving withheld in, with its unit tests, and saying plainly that no live audit has produced
one yet. It is designed behavior. I have never watched it happen.
The reply that looked complete and was not
Run 4 opened three issues and then crashed:
INFO Opened 3 issue(s).
ApifyApiError: Schema validation failed clientMethod: DatasetClient.pushItems
instancePath: '/issueNumber' message: 'must be integer'
The issues were real. I checked them from outside the run with an unauthenticated curl. The
report is what failed, because issueNumber came back null.
I had stored the first raw reply in the key-value store on purpose, so I could look instead of
guess. This is issue_write's answer, verbatim:
{"id":"5078084249","url":"https://github.com/ai-q-labs/github-repository-audit/issues/4"}
There is no number field. id is a string, and it holds GitHub's internal identifier. The
issue number — 4 — exists only inside the URL.
Reaching for id would have recorded issue "5078084249". Nothing would have errored. My dataset
would have looked complete and been wrong in a way no type check catches.
The fix reads the number where it actually lives, and refuses to invent one:
function findIssueRef(reply) {
const out = { number: null, url: null };
if (!reply) return out;
if (typeof reply === 'object') {
const o = reply.issue ?? reply.data ?? reply;
const n = o?.number ?? o?.issue_number ?? o?.id;
if (Number.isInteger(n)) out.number = n; // note: a string id fails this on purpose
const u = o?.html_url ?? o?.htmlUrl ?? o?.url;
if (typeof u === 'string') out.url = u;
if (out.number !== null && out.url !== null) return out;
}
const text = typeof reply === 'string' ? reply : JSON.stringify(reply);
const m = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)/.exec(text);
if (m) {
out.url ??= m[0];
if (out.number === null) out.number = Number(m[1]);
}
return out;
}
Number.isInteger is doing real work there. It rejects the string id that would otherwise sail
through.
If you build against a connector, capture the first reply from every tool you call. A tool result is
whatever the upstream server decided to send. Assuming a field name and discovering the truth in
production is exactly the failure this prevents.
Two more failures worth designing for
A run cannot open an arbitrary dataset. My very first run died here:
ACTOR: Running under "LIMITED_PERMISSIONS".
ERROR Could not read dataset iBbLEMf3NUSJcU0cZ: Insufficient permissions for the dataset.
Same account, same owner, still refused. The fix is not a token — it is the resourceType and
resourcePermissions declaration from the schema at the top of this article. The schema names the
thing being read and the thing being written through. The platform grants exactly that, and no more.
A run that changed the outside world must not fail on its own bookkeeping. That crash after
"Opened 3 issue(s)" marked the whole run FAILED. FAILED reads as "nothing happened". That is the
opposite of the truth, and it sends the next run straight back at the same findings.
I now guard each dataset write on its own. Anything that fails lands in the summary instead of
killing the run:
for (const d of decisions) {
try {
await Actor.pushData(d);
} catch (err) {
reportProblems.push({ key: d.key, error: String(err?.message || err).slice(0, 300) });
}
}
Order the work so the irreversible part happens last, and make everything after it non-fatal.
Seven runs got me here, and the first two are the ones worth reading:
The bug my new Actor found in my old one
Look again at issue #11. The registry link is git://github.com/aceakash/string-similarity.git.
That is on GitHub. The same row resolves aceakash/string-similarity and queues it for a GitHub
lookup. One row, two statements, contradicting each other.
My first explanation was that the URL parser rejects the git:// scheme. I ran the parser to check,
and it does not. It accepts git://, git+ssh://, git@host:path and github:owner/name alike.
The real cause is one line further on. The finding fires when the GitHub record is missing:
if (registryFound && !pkg.repoUrl) {
add('no_repository_link', 'The registry record does not link to any source repository, so nothing can be verified against it.');
} else if (registryFound && pkg.repoUrl && !repo) {
add('repository_not_on_github', `The registry links to ${String(pkg.repoUrl).slice(0, 120)}, which is not a GitHub repository.`);
}
The record is also missing when the lookup never ran. GitHub allows an unauthenticated caller 60
requests an hour. Past that, my Actor stops asking and stores null:
if (state.rateLimited) { state.skipped += 1; return null; }
So !repo means two different things, and the code reads only one of them. Every row cut off by the
rate limit is reported as a repository that is not on GitHub. That is why all 76 unverified rows were
paired. The pairing tracks the rate limit. The URL scheme has nothing to do with it.
It is the same mistake as the one in my first article, made a fifth time. A missing answer is not a
negative answer.
I have not fixed it. github-repository-audit is inside an unrelated observation window, and
changing its build would destroy that measurement. It is recorded, and it goes in next.
I did not expect the reader of an Actor's output to become the best test of that Actor. It is
obvious in hindsight. A dataset you only look at is a dataset nobody checks.
What it cost, and what I would do differently
Eleven issues, none duplicated, across four live runs and two audit datasets. All of it on the
free plan. When I stopped, my account usage read $0.61 against the $5 monthly credit. MCP
connectors are not a paid feature, which surprised me enough to check twice.
Three things I would tell myself before starting:
- Declare the smallest tool set you can name. Not for safety theater — because the declaration is public. Anyone can read what my Actor is allowed to do without reading my code.
- Fire the connector where the decision is, not where the output is. Read before you write, and stop if the read fails.
- Store the first reply from every tool. The one that cost me a run was shaped nothing like I assumed. It failed quietly, in the one direction a type check cannot catch.
And one I got wrong: I designed a decision path around a data shape I had never seen. A branch, a
reason string, and unit tests, for a row that has not appeared once in 178 audited packages. Next
time I look at the data before I write the branch.
Run it yourself
The full source of the Actor in this article is at
ai-q-labs/dataset-to-github-issues —
the input schema, the decision function, the connector client, and the unit tests, including the one
covering the case that has never fired.
You will need three things to run it: an audit dataset with riskLevel and issueCodes on each
row, an MCP connector authorized against GitHub, and a repository you are willing to file issues in.
A free Apify account covers the rest.
Where the findings came from:
github-repository-audit is public and free.
Everything it produced through the connector is open in the
tracker repository, tagged
[dep-drift], including the one that exposed my own parser bug.
Full-size versions of every screenshot in this article sit in the source repository above, under
docs/screenshots.
The shortest version of the whole thing: your connector shows you a menu. Your Actor declares what it
eats. The proxy is what makes the difference real.





Top comments (0)