DEV Community

Ai-Q Labs for Apify

Posted on

I gave my Actor a GitHub MCP connector. It could see 44 tools and use 4.

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"]
                }
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Apify Console listing the 44 tools discovered on the GitHub MCP connector at authorization time

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
Enter fullscreen mode Exit fullscreen mode

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.

Apify run log showing the line: The proxy exposes 4 tool(s) to this run, naming issue_read, issue_write, list_issues and search_issues

So there are three layers, and they do different jobs:

  1. The token. What the upstream service will honor. Enforced by GitHub, discovered on failure.
  2. The connector. What server this credential belongs to. Chosen once, in Console.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:

Apify dataset table listing each finding with its decision and reason: three skipped because an open issue already covers them, three filed with issue numbers 4, 5 and 6, two deferred over the ceiling

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.',
    );
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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.json166 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 }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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) });
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Apify run list for the Actor showing seven runs, including one that failed on dataset permissions and one that failed on a mistyped connector id

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.`);
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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.

GitHub issues list showing eleven dep-drift issues opened by the Actor across two audit datasets

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:

  1. 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.
  2. Fire the connector where the decision is, not where the output is. Read before you write, and stop if the read fails.
  3. 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)