Blog

  • A Small Conscience for the Machine

    A Small Conscience for the Machine

    The agent I’ve been building talks to customers on phone and chat and takes real actions on their behalf: reboot a device, open a ticket, reschedule a dispatch, run a speed test. It’s barred from one thing: changing contact info on the account. That goes through the self-service portal, not the model.

    The problem

    Each turn produces two outputs: the reply text, and a log of what actions were attempted and whether they succeeded. Nothing keeps them in sync. The model generates the next plausible line of dialogue without checking the log first. “I’ve opened a ticket for you” is a plausible thing to say whether or not the ticket actually got created.

    This showed up in the first few turns after a new action tool was wired in, not after an extended testing phase. The agent claimed success on an action the log showed had failed to fire.

    Example

    Customer asks to move an appointment. The scheduling action fails, timeout or a silent step failure. The model still says “you’re all set, moved to next week,” because that’s the plausible reply to that request. The customer hangs up believing it’s done. Nobody shows up to the old slot, and the next contact is a missed-appointment notice for an appointment the agent said it had already moved.

    Why voice makes it worse

    There’s no slack in a phone call for a pause while something double-checks itself; a pause reads as broken and people talk over it or hang up. A false claim that’s merely annoying in a chat window becomes something a person hears in a confident voice and believes outright.

    The fix

    A pass runs on the reply text after generation, before the customer sees it:

    • Scan for language claiming a concrete action completed: ticket opened, appointment moved, reboot sent, speed test run.
    • Check each claim against the action log for that turn. Strip any claim without a matching success.
    • If the customer explicitly asked to change contact info, redirect to the portal link instead of a vague reply that reads as a yes.
    • If stripping leaves nothing useful, fall back to a plain line: can help with that, don’t have it available right now.
    • Log every strip. A repeated pattern on one action shows up later instead of staying buried in a transcript.

    Where that leaves things

    A narrow filter for one kind of confident overreach, not a smarter model. Its only job is making sure the agent doesn’t say “done” unless the log agrees.

  • Forking Net::Radius::Packet for mid-session CoA against a CGNAT-enabled BNG

    Forking Net::Radius::Packet for mid-session CoA against a CGNAT-enabled BNG

    A while back I forked Net::Radius::Packet into its own module for a project called radiusNE, instead of subclassing it or patching it in place. I hadn’t looked at the diff in long enough that I’d genuinely forgotten what was in it, so this post is as much me rediscovering my own code as it is writing it up.

    The actual reason for the fork

    The project sits in front of a CGNAT-enabled BNG platform, managing IPoE subscriber sessions. The specific thing I needed to do was alter a session already in progress, mid-flight, without the subscriber reconnecting: flipping a captive portal on or off for a given session being the clearest example. That’s not something a normal Access-Request/Access-Accept exchange handles, because that exchange only fires when a session is being established. Changing something on a session that’s already up means originating a CoA-Request (or a Disconnect-Request, if the goal is to kick the session entirely) from the RADIUS side and sending it to the NAS.

    Net::Radius::Packet can decode CoA and Disconnect packets fine, and it can respond to requests it receives, but building one to send in the first place is a different problem, and it’s where the fork actually starts.

    CoA and Disconnect have their own authenticator rules

    A normal Access-Request authenticator is 16 random bytes the client picks. A response authenticator is computed from the request. CoA-Request and Disconnect-Request follow neither pattern: RFC 5176 has them use the same scheme as Accounting-Request from RFC 2866. Pack the whole packet with 16 zero bytes standing in for the authenticator, MD5 that against the shared secret, then splice the digest into the authenticator field before sending. Order matters here. The digest has to be computed over the packet as if the authenticator were still zero, not over the final packet with a placeholder already swapped in.

    The fork adds a dedicated pack_coa() that does exactly this, along with paired accessors for the authenticator value so it can be stashed and retrieved cleanly instead of getting tangled up with the request/response authenticator the base module already manages:

    sub set_authenticator_coa {
        my $authenticator_placeholder = "\x00" x 16;
    }
    
    sub get_authenticator_coa {
        my ($self) = @_;
        return exists $self->{AuthenticatorCOA} ? $self->{AuthenticatorCOA} : ("\x00" x 16)
    }

    and the packing itself:

    my $authenticator_placeholder = $self->get_authenticator_coa();
    my $packet_header = pack($p_hdr, $codes{$self->code}, $self->identifier,
        $total_packet_length, $authenticator_placeholder);
    my $packet_before_authenticator = $packet_header . $attstr;
    my $authenticator = Digest::MD5::md5($packet_before_authenticator . $shared_secret);
    substr($packet_before_authenticator, 4, 16, $authenticator);
    return $packet_before_authenticator;

    IPv6 prefix encoding, for real this time

    The upstream module’s own documentation is honest about this one: ipv6addr, date, and ifid are, in its own words, simply mapped to other types until correct encoding is implemented. ipv6prefix and tagged-ipv6prefix aren’t handled upstream at all. For dual-stack IPoE sessions on that platform that’s not a cosmetic gap, since Framed-IPv6-Prefix and similar attributes need to actually round-trip correctly for a session to come up with working IPv6, not just IPv4.

    The fork adds real pack and unpack for both, using inet_pton/inet_ntop against the RFC-defined wire format (a reserved byte, a prefix length byte, then 16 bytes of address):

    "ipv6prefix" => sub {
        my $prefix = $_[0];
        my ($ipv6, $length) = split('/', $prefix);
        my $packed_ipv6 = inet_pton(AF_INET6, $ipv6);
        if (length($packed_ipv6) != 16) {
            warn "Invalid IPv6 address length";
        }
        return pack("C C a16", 0, $length, $packed_ipv6);
    }

    with the corresponding unpack turning the wire bytes back into a human-readable address/prefixlen string. Small in isolation, but this is the piece that actually fixed dual-stack sessions rather than just working around them.

    Tagged prefixes, and a bit of debugging courtesy

    The plain prefix handling above covers ipv6prefix, but there’s a tagged variant too, tagged-ipv6prefix, used where an attribute needs to carry a tag byte ahead of the usual reserved/length/address fields (the same tagging convention RFC 2868 uses elsewhere). The fork adds that as well:

    "tagged-ipv6prefix" => sub {
        my ($tagged) = @_;                       # "5,2001:db8::/48"
        my ($tag, $prefix) = split /,/, $tagged, 2;
        my ($addr, $plen) = split '/', $prefix;
        my $packed = inet_pton(AF_INET6, $addr)
                or die "inet_pton failed for $addr";
        return pack 'C C C a16', $tag, 0, $plen, $packed;
    }

    with a matching unpack on the receive side, so tagged prefixes round-trip the same way plain ones do.

    The other change is smaller but has saved more debugging time in practice. Stock behavior when the VSA-packing loop hits an attribute type it doesn’t recognize is to just skip it, silently. On a live NAS exchange, a VSA that quietly failed to make it into the outgoing packet is exactly the kind of thing that burns an afternoon before you think to suspect it. The fork adds a warning at that exact point instead:

    unless ( defined $type && exists $vsapacker{$type} ) {
        carp "Unknown VSA $vid/$attr – dropped" if $self->{unknown_entries};
        next;
    }

    Same fallback behavior, but now it says so.

    Making CoA-NAKs readable

    RFC 5176 defines a set of numeric Error-Cause values a NAS can return in a NAK, and out of the box you just get the number. The fork adds an errorCodes() lookup covering the RFC 5176 causes, from residual session cleanup (201) through the various fatal request errors (401 through 407) to the proxy and resource ones (501 through 508), each with both the short cause and the fuller RFC description. Debugging a rejected CoA against that box goes from grepping the RFC for what error 403 means to just reading it off the response.

    Why a fork instead of a patch

    All of this could theoretically have been contributed upstream or maintained as a patch series, but a CoA workflow specific to that platform with a fairly specific captive-portal use case is not something I’d expect the general module to want to carry, and patch series rot the moment the upstream module moves out from under them. A standalone copy, called directly instead of through the usual namespace, keeps it simple: what’s in the file is what’s running, no patch application step, no version skew to track.

    The tradeoff is exactly what you’d expect: any future upstream fix or improvement to Net::Radius::Packet has to be manually ported over if I want it, since there’s no dependency link back to the original at all. For a fork this targeted, that’s been a fair trade so far.

  • Lions 31, Jets 24: More Survival Than Victory

    A win is a win, but that felt a lot more like survival than victory.

    The Lions were up 24-10 and looked like they had the game under control. Then the defense did what it has been doing and let the Jets right back into it. A 14-point lead disappeared in the fourth quarter and suddenly it was 24-24.

    Fortunately, the offense answered. Goff played another really clean game, and Gibbs was ridiculous: 99 yards rushing, 65 receiving and three touchdowns. When Detroit needed the lead back, they got it.

    The defense is still the concern. Geno Smith went 31-of-37 for 321 yards and three touchdowns, and the Jets were driving for a potential tying score before Chuck Clark finally ended it with the strip sack.

    There were some positives. Detroit held the Jets to 58 yards rushing, got five sacks and finally produced the big defensive play when they absolutely had to have one.

    So I’ll take 2-1.

    But I’d really like to watch a Lions game where the fourth quarter doesn’t turn into a survival exercise.

  • Falcons 35, Packers 14: A Thursday Night for the Rest of the Division

    Everyone loves an underdog. It’s even easier to love one when the team on the other sideline is Green Bay, and for roughly three quarters of the NFC North, Thursday night was pure entertainment. If you live by FTP, this one was for you.

    On paper it looked like a get-right game for the Packers. Atlanta came into Lambeau 0-2 under a brand new head coach in Kevin Stefanski, with an offense that had scored 16 points total across two weeks and hadn’t run a single play in the red zone. Green Bay was a five-point favorite in its home opener. Michael Penix Jr. was making his first start of the season, coming back from the partially torn ACL that ended his last one.

    For about one drive, the script held. Penix threw an interception on Atlanta’s opening possession, and Christian Watson caught his fourth touchdown of the year to put Green Bay up 7-0. I figured I’d be changing the channel by halftime.

    Then Atlanta scored 27 straight points.

    Penix settled in and went 13-of-15 with a touchdown across the second and third quarters. He finished 18-of-25 for 256 yards, one touchdown and the one early pick. That’s a quarterback who shook off a bad start instead of letting it snowball, which is more than I can say for some quarterbacks I watch every Sunday.

    The real damage came from Drake London and Bijan Robinson. London had nine catches for 194 yards after managing six for 80 in the first two games combined. Robinson ran it 29 times for 194 yards and two touchdowns, plus two catches for 19 more. For context, he’d had 80 rushing yards total through two weeks. According to the stat nerds, they’re the first pair of teammates to each clear 190 yards from scrimmage in a road game since 1976. Doing that at Lambeau is a nice touch.

    Green Bay’s run game, meanwhile, produced 17 yards. Seventeen. Jordan Love threw it 53 times for 312 yards and two touchdowns, and Matthew Golden got his first career touchdown with a 100-yard night, but most of that came while the Packers were chasing a game that was already gone. Atlanta outgained them 498 to 328 and went 6-of-10 on third down. It would have been over 500 yards if not for a couple of kneel-downs at the end.

    Final: Falcons 35, Packers 14. It’s Green Bay’s first home-opener loss since 2012, and it leaves them at 1-2, with the Week 1 loss to Minnesota already on the books.

    I’m not going to pretend three weeks settles anything in this division. Green Bay will be fine eventually; they usually are, which is exactly why a night like this is so satisfying. As a Lions fan, I’ll take a Packers team that couldn’t run the ball or stop the run at home against an 0-2 team, and I’ll enjoy it for the rest of the weekend. The Lions have their own problems to sort out against the Jets on Sunday, but that’s a post for another day.

  • The logo saga: when a matching file size isn’t proof of anything

    The logo saga: when a matching file size isn’t proof of anything

    The theming post covered the palette and the read-only wall around global styles. What it didn’t cover was the logo, because that turned into its own small disaster, and this is the honest account of it.

    A file that looked fine and wasn’t

    The original logo was black ink on a transparent PNG, invisible against the new dark background. Recoloring it to off-white was simple enough with Pillow. Getting the result into WordPress was the actual problem.

    There’s no upload tool here that reads a local file directly. The only path is base64: encode the file, paste the encoded text as a parameter, WordPress decodes it on the other end. For a 25KB image that’s roughly 33,000 characters of base64. Typing that out is not really typing, it’s an AI regenerating a long span of near-random text token by token, and that turned out to be exactly as reliable as it sounds.

    The upload succeeded. The file size matched the original byte for byte. Everything about it looked correct until it rendered on the actual page: only the top edge of the letters showed, the rest cut off clean. Sampling the live file’s pixels directly confirmed it, the bottom half of the image had gone fully transparent, silently, somewhere in that 33,000-character reproduction. A single wrong character in the compressed image data was enough to corrupt everything after it, while leaving the total file length untouched. Matching byte counts turned out to be worthless as an integrity check.

    Fixing it properly instead of retrying blind

    Retyping the same 33,000 characters again would have had the same odds of failing the same way. Two changes instead:

    • Shrink the file. The logo only ever displays at 120 pixels wide, so the original 783px source was overkill. A resize to 180px, plus posterizing the alpha channel to fewer steps, cut the base64 payload by more than half.
    • Verify by actually reading the pixels back, not by trusting a file size match. After the second upload, the live file’s alpha channel got sampled row by row in the browser and compared against the local source. Small rounding differences from PNG re-encoding, sure. No blank regions.

    That one held up. Byte-for-byte size match, row sums lining up, letters intact top to bottom.

    Where a human stepped in

    Even with a verified file, there’s still no write tool for the block that actually sets the logo in the header or footer template. That’s a Site Editor action: open the block, pick the image, save. Every version of the logo, correct or corrupted, needed a manual click to actually go live. This happened twice, once for the corrupted file and once for the fixed one, which is also how the corruption got caught in the first place. It rendered wrong on a live page, not in a status message claiming success.

    The manual step didn’t stop there. After the second upload, the fix still didn’t hold, because the file needed pointing at the block a second time and the interim result kept the old broken file wired in. In the end the working logo on the site right now isn’t the AI-generated one at all, it’s a version made directly in GIMP and uploaded through the ordinary media library flow, sidestepping the whole base64 problem entirely.

    Where that leaves things

    Passing large binary data through a text-based tool call is a real failure mode, not a hypothetical one, and a matching file size is not proof that the content survived the trip. The fix that actually held wasn’t a smarter retry, it was shrinking the problem and verifying the result against ground truth instead of trusting a success message. And some things, like pointing a template block at a specific media file, just need a hand on the mouse.

  • Bills 41, Lions 31: The Defense Is the Story Two Weeks Running

    Same result as last week in one sense: another close-ish final score. Everything else about how it felt was worse.

    Detroit went into Buffalo already missing pieces up front, with two starting offensive linemen out and both starting safeties unavailable. Josh Allen and the Bills offense made that count immediately. Buffalo scored on their first three drives and it was 21-0 before the Lions ran a single successful play that mattered. By halftime it was 27-10.

    Watching it live, the offense looked like it couldn’t decide what it wanted to be early on. Goff started spreading throws to whoever was open, then within a series or two it was back to feeding Gibbs almost by reflex, like the play-calling defaulted to its most comfortable answer instead of what the defense was actually giving up. Gibbs finished with just 52 yards on 16 carries on the ground, which tells you Buffalo had that answer scouted and Detroit kept asking the question anyway for a stretch.

    The second half was a different team. Once Goff started living through Jameson Williams, Amon-Ra St. Brown, and Sam LaPorta instead of forcing the ground game, Detroit actually outscored Buffalo 21-14 the rest of the way. St. Brown finished with 142 yards and two touchdowns, Goff ended the night 327 yards and four touchdown passes, and for about a quarter and a half it looked like a real comeback instead of garbage-time stat padding.

    Then the defense gave up 41 points, for the second straight week after allowing 30 to the Saints. Two straight games of that is a pattern, not a blip. And it’s happening with the two starting safeties already out and the offensive line already missing two starters, which is exactly the kind of situation where thin depth turns a bad week into a genuinely ugly one. Allen finished with 317 total yards and five total touchdowns and the Bills converted 9 of 12 third downs. Detroit’s defense was beaten and gassed, on the field almost the entire night.

    Final: Bills 41, Lions 31. A game the offense nearly stole back in the fourth quarter, and a defense that made that theft necessary in the first place.

    Jets come to Ford Field next. Plenty of time to figure out who’s walking through that door to help the back end of this defense, though at this point I’m not holding my breath.

  • Lions 31, Saints 30: A Win That Felt Like a Loss

    Full disclosure up front: I’m not a football theory guy. I couldn’t diagram a cover-2 if you paid me. I watch as a casual fan who likes the Lions, yells at the TV a little, and moves on with my day. So take these as impressions, not analysis.

    Sunday’s game against the Saints was a weird one to sit through, because Detroit won 31-30 and it still didn’t feel like a win.

    The first half and change went about how you’d hope. Detroit built a 21-0 lead through the third quarter on two Jahmyr Gibbs rushing touchdowns and a Jared Goff touchdown pass to Amon-Ra St. Brown, and the Saints hadn’t put a point on the board yet. This is the part of the game I actually enjoyed watching.

    Then it fell apart. New Orleans scored on four straight possessions, a touchdown, another touchdown, a field goal, and a third touchdown, to erase the entire lead and tie the game at 24 with just over a minute left in the fourth quarter. A 21-0 lead turned into a tie game that fast. Their quarterback finished with 410 passing yards on the day, which is a number that should not be possible against a defense that also racked up five sacks and two interceptions. Both things happened in the same game.

    Detroit did answer. Goff found St. Brown again for a short touchdown to make it 31-24 with barely a minute on the clock, which should have sealed it. Instead the Saints marched right back down and scored again to make it 31-30, then went for the two-point conversion and the win instead of the extra point and overtime. The pass fell incomplete, and that’s the game.

    So Detroit wins by one point after giving up 30 unanswered and needing the Saints to miss a two-point try to close it out. The Lions also lost the total yardage battle, 369 to 469, and Detroit held the ball for five more minutes than New Orleans did while still surrendering nearly 500 yards. None of that shows up in the final score, but it’s exactly why the game felt like it did.

    I know a win is a win, and I’m sure there are Lions fans out there right now telling me to stop complaining about a victory. Fair. But if you were watching live, you know exactly the feeling I mean: that slow drain where a comfortable laugher turns into a nail-biter you didn’t ask for, and by the end you’re relieved more than happy.

    On to Buffalo tonight.

  • News Roundup: September 7–16, 2026

    A heavier week than the last one. The MikroTik router chain I flagged in the last roundup turned out to be a live zero-day, a popular WordPress events plugin picked up a 9.8 RCE, and Cisco dropped another advisory batch.

    • MikroTik’s “no details yet” RouterOS release from the last roundup turned out to be actively exploited. CERT Polska disclosed the chain, dubbed MikroTrick: CVE-2026-67276 (SSH auth bypass, CVSS 9.2) combined with CVE-2026-86060 (privilege escalation) gives an unauthenticated attacker full admin on any RouterOS device with SSH reachable from the internet. Exploitation reportedly started September 2, a day before the patch shipped, so this was a zero-day in practice. CISA added both CVEs to its KEV catalog on September 10, and Shadowserver counted roughly 122,500 internet-exposed RouterOS SSH endpoints as of September 5. If you haven’t patched to 7.24.2, 7.23.4, or 6.49.21 yet, do that now, then check for a “Flagged” device status and an unexpected user named something like “ops” before assuming you’re clean.
    • The Events Calendar plugin patched two critical code-injection bugs. CVE-2026-78159 (CVSS 9.8) is an unauthenticated code injection from insufficient validation; a second flaw, CVE-2026-78006, is also critical. Both need comments enabled on the plugin to be exploitable. Fixed in 6.17.3.1, but the plugin has over 600,000 active installs and download data suggests roughly half may still be sitting on a vulnerable version.
    • Cisco’s September 16 advisory batch covers hardening releases for BroadWorks CommPilot, Identity Services Engine, Nexus Dashboard, ThousandEyes Virtual Appliance, and a combined release for all three Secure Firewall products (ASA, FMC, and FTD). Worth calling out separately: the same batch includes a Secure Email Gateway hardening release with a SQL injection flaw that Cisco says is already being exploited, which bumps it above the usual “patch when convenient” priority.
    • WordPress 7.1.1 is still on track as a bug-fix-only release for tomorrow, September 17, covering 16 core tickets and 22 Gutenberg pull requests. No security content in this one, but if anything you run leans on responsive styles, the Icon Registration API, or the iframed editor, it’s worth a spin on staging before it lands.
  • Theming the site: a dark palette, a read-only wall, and two follow-up fixes

    Theming the site: a dark palette, a read-only wall, and two follow-up fixes

    Two posts in, this site was still using WordPress defaults with zero customization. Given the subject matter, that felt like an oversight. So the next request was simple: restyle the theme, pick a direction based on the site’s own content, and make it happen. It mostly worked. The interesting part was where “mostly” showed up.

    The direction

    Twenty Twenty-Five, the active theme, is a block theme, which means colors, typography, and spacing all live in a theme.json file rather than scattered across a Customizer panel. Given the content here is server racks, networking gear, and ISP work, the natural direction was something dark and technical rather than another white background with black text: deep slate, off-white body copy, a signal-green accent, and monospace for anything code-related. Status-light green felt more honest to the subject matter than a generic blue.

    Read access, no write access

    Here is where it got interesting. The MCP plugin exposes a tool to read the site’s current global styles, but nothing to write them. The wp_global_styles object isn’t a normal post either. Its REST endpoint expects a JSON body with top-level styles and settings keys, not the title and content fields every other post type uses, so even the generic custom-post-type update tool couldn’t touch it. Forcing a write through a mismatched schema on a live site was a bad trade for saving a few minutes, so instead of guessing, the actual theme.json got handed over as text to paste in directly.

    That meant a child theme rather than an edit to Twenty Twenty-Five itself, so a future core update to the parent theme wouldn’t wipe out the customization. Two small files, a folder created over SSH, activate from the dashboard. From there it was a normal back and forth: apply the palette, load the site, screenshot it, spot what needed adjusting.

    What needed a second pass

    The first version looked right at a glance but missed two things once actually rendered:

    • Inline code, the kind that shows up mid-sentence, wasn’t styled at all. Only the block-level code element had a rule, so a word like xfs_growfs in a sentence just inherited body text. Needed its own entry in the theme.json elements block.
    • Nav links stayed off-white instead of picking up the accent color. Block themes sometimes save color settings directly on a template part through the Site Editor, which can override theme.json for that one instance. Adding an explicit core/navigation block style to the file fixed it without needing to touch the Site Editor at all.

    Both went back into the same theme.json, pasted in once, confirmed live with a screenshot afterward rather than assumed. Inline code now has a dark background with green monospace text, matching the block-level code it sits next to, and the nav bar is green across the board.

    Where that leaves things

    The gap between what a plugin can read and what it can write is worth remembering. It’s tempting to assume access implies control, but here the honest answer was to say so and hand off a copy-paste step rather than force a fix that might have corrupted live styling. The site now actually looks like it belongs to an ISP nerd instead of a stock WordPress install, and the process for getting there, read what exists, design deliberately, verify by looking rather than assuming, is the part worth keeping around for next time.

  • Getting Codex connected to WordPress through MCP

    I finally got Codex talking to this WordPress site through MCP. The short version: the local config was close, the WordPress endpoint itself was fine, but the thing that actually made the tools show up here was setting it up through the web interface as a plugin and then reloading the client.

    What did not work at first

    I started with a local MCP server entry in the Codex config pointing at this site’s Easy MCP AI endpoint:

    [mcp_servers.wordpress]
    enabled = true
    url = "https://example.com/wp-json/easy-mcp-ai/v1/mcp"
    bearer_token_env_var = "WORDPRESS_MCP_TOKEN"

    That looked right on disk, but it did not make the WordPress tools appear in the running chat. The config was present, the server was enabled, and the endpoint URL was correct, but Codex’s live tool list still only showed the built-in app/plugin resources. In other words: configured is not the same thing as loaded.

    The token/header trap

    The other easy mistake was treating the token name as if it were the header name. Putting a value under a header called WORDPRESS_MCP_TOKEN is not the same thing as setting an environment variable named WORDPRESS_MCP_TOKEN, and it is not the same thing as sending Authorization: Bearer ....

    When I tested the WordPress endpoint directly with a bearer token, the MCP initialize request came back clean. The server identified itself as easy-mcp-ai and advertised tools/resources for managing posts, pages, media, comments, users, and site settings. So the WordPress side was alive. The problem was getting Codex to expose it as an actual callable tool in the session.

    What finally worked

    The working path was to add it through the web interface as a plugin, then reload the Codex client and resume the chat. After that, the WordPress MCP namespace appeared immediately, with tools like:

    • wp_list_posts
    • wp_get_post
    • wp_create_post
    • wp_update_post
    • wp_list_media
    • wp_get_site_settings

    The first real verification was intentionally boring: list one recent post. That returned a scheduled post and proved the connection was not just installed-looking, but actually usable.

    The practical checklist

    • Install and configure the Easy MCP AI plugin on WordPress.
    • Generate a token from the WordPress side.
    • Add the MCP endpoint through the web/plugin interface, not just local config, if you want it available inside this Codex session model.
    • Reload the Codex client after adding it.
    • Verify with a read-only call first, like listing posts or reading site settings.

    That last bit matters. A connector can be present in a settings screen, reachable over HTTPS, and still not be available to the model as a tool. The only test that counts is whether the WordPress tools actually show up and a harmless read works.

    Net result: this post was created from Codex through the WordPress MCP plugin, which is probably the cleanest possible proof that the setup is working.