From 07fb5241f4c23777f7486b9481636adeb4258c3d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:45:18 +0700 Subject: [PATCH 1/8] =?UTF-8?q?fix(backend):=20add=20dns=20ipv4first=20?= =?UTF-8?q?=E2=80=94=20Temporal=20gRPC=20ECONNREFUSED=20::1:7233?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's Temporal client was resolving the temporal hostname to IPv6 ::1 (loopback) instead of the Docker network IPv4 address 172.18.0.x. This caused ALL workflow starts to silently fail (D6 pattern: ECONNREFUSED swallowed by catch block). --- apps/backend/src/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 3c19b457d7..a7579e9bb7 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,5 +1,7 @@ import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; initializeSentry('backend', true); +import dns from 'dns'; +dns.setDefaultResultOrder('ipv4first'); import compression from 'compression'; import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger'; From 7e084735cab4fad948290f192a93039ec18b4446 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:50:41 +0700 Subject: [PATCH 2/8] fix(ci): give the bootstrap test gate a real Postgres and Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Build workflow has been red on every push to dev and main since at least 2026-09-10 (PRs #33-#36). The failing step is "Test bootstrap / OAuth consent (jest)": those suites guard against running on a shared database and throw 'Use isolated local test services' unless DATABASE_URL points at 127.0.0.1:15491 (or :15432) and REDIS_URL at 127.0.0.1:16391 (or :16379). CI provided neither, so beforeAll threw, prisma stayed undefined, and afterAll then failed with "Cannot read properties of undefined (reading 'oAuthAuthorization')" — surfacing as 42 failed / 11 passed of 53. Add Postgres 17 and Redis 7.2 service containers on exactly those ports, push the Prisma schema into the throwaway database before the run, and add --runInBand because the suites share one database and create/delete rows by known ids. Verified locally against the same images, ports, env and commands: Test Suites: 5 passed, 5 total Tests: 53 passed, 53 total Jest exits cleanly, so no --forceExit is needed. This does not make CI a trustworthy gate on its own. The ESLint workflow is still red (eslint 8.57 cannot consume the flat eslint.config.mjs, and fixing that needs eslint 9 plus @typescript-eslint 8), and it is "Build & Publish Crove Containers" — not this workflow — that produces the deployable image. Those are separate follow-ups. --- .github/workflows/build.yml | 45 ++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4628de632..ada6989534 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,40 @@ jobs: matrix: node-version: ['22.12.0'] + # The bootstrap / OAuth-consent suites refuse to run unless DATABASE_URL + # points at 127.0.0.1:15491 (or :15432) and REDIS_URL at 127.0.0.1:16391 + # (or :16379) — they throw 'Use isolated local test services' otherwise, + # which used to fail this job on every push. These ports are deliberate: + # they match the disposable local services the suites were written against. + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: crove + POSTGRES_PASSWORD: crove + POSTGRES_DB: crove_test + ports: + - 15491:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7.2 + ports: + - 16391:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://crove:crove@127.0.0.1:15491/crove_test + DATABASE_DIRECT_URL: postgresql://crove:crove@127.0.0.1:15491/crove_test + REDIS_URL: redis://127.0.0.1:16391 + steps: - name: Checkout Code uses: actions/checkout@v4 @@ -47,8 +81,17 @@ jobs: - name: Test SSO Worker (vitest) run: pnpm run test:sso + # The suites drive the real Prisma client, so the schema must exist in + # the throwaway database first. --skip-generate because the postinstall + # hook already generated the client. No --accept-data-loss: this is an + # empty database and the step must stay non-destructive. + - name: Push Prisma schema to the test database + run: pnpm exec prisma db push --skip-generate --schema libraries/nestjs-libraries/src/database/prisma/schema.prisma + - name: Test bootstrap / OAuth consent (jest) - run: pnpm exec jest --config tests/bootstrap.jest.cjs --ci --passWithNoTests + # --runInBand: the suites share one database and create/delete rows by + # known ids, so they must not run concurrently against each other. + run: pnpm exec jest --config tests/bootstrap.jest.cjs --ci --passWithNoTests --runInBand - name: Build applications run: pnpm run build From 9ad113b99cdd4802c809f7bc7c3015e154e91fc8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:51:09 +0700 Subject: [PATCH 3/8] fix(security): fail closed when MOBILE_APP_SCHEME is unset GET /oauth-mobile-callback holds a live OAuth authorization code and redirected it to `process.env.MOBILE_APP_SCHEME || 'postiz://auth/callback'`. Deployments that never set the variable therefore handed this instance's authorization codes to whatever app on the user's device registered the upstream `postiz://` scheme. Crove ships no mobile app and leaves the variable empty, so that fallback was the live behaviour, not a theoretical one. Return 501 instead of redirecting when the scheme is unset. The frontend had the same defect from the other side: it hardcoded `postiz://integrations` as the mobile deep link and never read MOBILE_APP_SCHEME, so the two halves could not agree even when the variable was configured. Add getMobileAppScheme, which derives just the scheme prefix from the same variable the backend consumes, thread it through the variable context and the three layouts, and omit redirectUrl entirely when no scheme is configured. Also drop the upstream scheme from the .env.example default, since the documented example value was the exact string that caused the leak. Eight contract tests in the branding guard cover the derivation, including that an unset, absent, whitespace-only or separator-less value yields '' so callers fail closed rather than falling back. --- .env.example | 9 +++++-- .../backend/src/api/routes/auth.controller.ts | 12 +++++++++- apps/frontend/src/app/(app)/layout.tsx | 2 ++ apps/frontend/src/app/(extension)/layout.tsx | 2 ++ apps/frontend/src/app/(provider)/layout.tsx | 2 ++ .../launches/add.provider.component.tsx | 19 +++++++++------ .../helpers/src/utils/mobile.app.scheme.ts | 24 +++++++++++++++++++ .../src/helpers/variable.context.tsx | 7 ++++++ scripts/branding-guard.ts | 15 ++++++++++++ 9 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 libraries/helpers/src/utils/mobile.app.scheme.ts diff --git a/.env.example b/.env.example index 1c616e5c27..5a036a4d42 100644 --- a/.env.example +++ b/.env.example @@ -47,8 +47,13 @@ RUN_CRON="true" MCP_ONLY="false" # Disable Secure flag on cookies for non-HTTPS local development (optional) NOT_SECURED="false" -# Custom mobile application URL scheme for auth callbacks (optional) -MOBILE_APP_SCHEME="postiz://auth/callback" +# Custom mobile application URL scheme for auth callbacks. +# LEAVE EMPTY unless this deployment ships its own mobile app. The backend +# redirects a live OAuth authorization code to this scheme, so any value here +# hands that code to whichever app registered it on the device. When empty, +# GET /oauth-mobile-callback refuses with 501 instead of redirecting, and the +# frontend omits the mobile deep link entirely. +MOBILE_APP_SCHEME="" # Hourly rate limit for public API requests per organization API_LIMIT="100" diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index 2ed4482447..809e441b62 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -206,7 +206,17 @@ export class AuthController { @Query('state') state: string, @Res({ passthrough: false }) response: Response ) { - const scheme = process.env.MOBILE_APP_SCHEME || 'postiz://auth/callback'; + const scheme = (process.env.MOBILE_APP_SCHEME || '').trim(); + // Fail closed. This handler holds a live OAuth authorization code, and a + // custom-scheme redirect delivers it to whichever app on the device + // registered that scheme. Falling back to an upstream default would hand + // this deployment's codes to someone else's app, so an unset + // MOBILE_APP_SCHEME must refuse rather than redirect. + if (!scheme) { + return response + .status(501) + .send('Mobile OAuth callback is not configured for this deployment'); + } const params = new URLSearchParams(); if (code) params.set('code', code); if (state) params.set('state', state); diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index b2ca93b5e9..9495eca762 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -11,6 +11,7 @@ import PlausibleProvider from 'next-plausible'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import { Fragment } from 'react'; import { PHProvider } from '@gitroom/react/helpers/posthog'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; @@ -77,6 +78,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={!!process.env.STRIPE_PUBLISHABLE_KEY} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/app/(extension)/layout.tsx b/apps/frontend/src/app/(extension)/layout.tsx index 5aa92fbebd..c4d070af2f 100644 --- a/apps/frontend/src/app/(extension)/layout.tsx +++ b/apps/frontend/src/app/(extension)/layout.tsx @@ -8,6 +8,7 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; const jakartaSans = Plus_Jakarta_Sans({ @@ -47,6 +48,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={false} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/app/(provider)/layout.tsx b/apps/frontend/src/app/(provider)/layout.tsx index ca63a98e5d..fc1737d723 100644 --- a/apps/frontend/src/app/(provider)/layout.tsx +++ b/apps/frontend/src/app/(provider)/layout.tsx @@ -10,6 +10,7 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; const jakartaSans = Plus_Jakarta_Sans({ @@ -49,6 +50,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={false} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 9ff651acb7..b47070b11e 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -406,7 +406,7 @@ export const AddProviderComponent: FC<{ isMobile?: boolean; }> = (props) => { const { update, social, article, onboarding, isMobile } = props; - const { isGeneral, extensionId } = useVariables(); + const { isGeneral, extensionId, mobileAppScheme } = useVariables(); const toaster = useToaster(); const router = useRouter(); const fetch = useFetch(); @@ -466,14 +466,19 @@ export const AddProviderComponent: FC<{ }; const gotoIntegration = async (externalUrl?: string) => { // Mobile WebView: reuse the existing `externalUrl` param to - // carry the `postiz://` deep link so the backend redirects - // back to the iOS/Android app after OAuth completes, instead - // of the default web redirect. + // carry the app deep link so the backend redirects back to the + // iOS/Android app after OAuth completes, instead of the default + // web redirect. The scheme comes from MOBILE_APP_SCHEME — the same + // variable the backend's /oauth-mobile-callback uses — so the two + // halves cannot drift. Empty scheme means no mobile app is + // configured, so no redirectUrl is sent at all. const params = [ `externalUrl=${encodeURIComponent(externalUrl)}`, onboardingParam, - isMobile - ? `redirectUrl=${encodeURIComponent('postiz://integrations')}` + isMobile && mobileAppScheme + ? `redirectUrl=${encodeURIComponent( + `${mobileAppScheme}integrations` + )}` : '', ] .filter(Boolean) @@ -511,7 +516,7 @@ export const AddProviderComponent: FC<{ // `window.open`/`location.href` aren't reliable here because // RN WebView doesn't always route them through the native // navigation intercept. The backend redirects back to the - // app via `postiz://` once OAuth completes. + // app via the MOBILE_APP_SCHEME deep link once OAuth completes. const rn = (window as any).ReactNativeWebView; if (rn && typeof rn.postMessage === 'function') { rn.postMessage(JSON.stringify({ type: 'open-external', url })); diff --git a/libraries/helpers/src/utils/mobile.app.scheme.ts b/libraries/helpers/src/utils/mobile.app.scheme.ts new file mode 100644 index 0000000000..a4fc26d0a1 --- /dev/null +++ b/libraries/helpers/src/utils/mobile.app.scheme.ts @@ -0,0 +1,24 @@ +/** + * Extract the URL-scheme prefix ("postiz://") from a full MOBILE_APP_SCHEME + * callback URL ("postiz://auth/callback"). + * + * The backend owns MOBILE_APP_SCHEME and consumes it as a complete redirect + * target in GET /oauth-mobile-callback. The frontend only needs the scheme, to + * build deep links such as "integrations". Deriving it here keeps both + * halves on one variable instead of the frontend hardcoding a second scheme + * that silently drifts from the backend's. + * + * Returns '' when the variable is unset or malformed, so callers can omit the + * deep link entirely rather than sending an upstream-branded one. Deployments + * without a mobile app must leave MOBILE_APP_SCHEME empty. + */ +export function getMobileAppScheme( + env: Record = process.env +): string { + const raw = ( + env.MOBILE_APP_SCHEME || env.NEXT_PUBLIC_MOBILE_APP_SCHEME || '' + ).trim(); + // RFC 3986 scheme: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":" + const match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)/.exec(raw); + return match ? match[1] : ''; +} diff --git a/libraries/react-shared-libraries/src/helpers/variable.context.tsx b/libraries/react-shared-libraries/src/helpers/variable.context.tsx index 130a26ff3b..9313789235 100644 --- a/libraries/react-shared-libraries/src/helpers/variable.context.tsx +++ b/libraries/react-shared-libraries/src/helpers/variable.context.tsx @@ -15,6 +15,12 @@ export interface VariableContextInterface { oauthLogoUrl: string; oauthDisplayName: string; mcpUrl?: string; + /** + * URL-scheme prefix derived from MOBILE_APP_SCHEME (e.g. "postiz://"). + * Empty when the deployment has no mobile app, in which case callers must + * not emit a deep link at all. + */ + mobileAppScheme?: string; cloudflareUrl: string; mainUrl: string; frontEndUrl: string; @@ -57,6 +63,7 @@ const VariableContext = createContext({ googleAdsTrialTracking: '', oauthDisplayName: '', mcpUrl: '', + mobileAppScheme: '', cloudflareUrl: '', mainUrl: '', frontEndUrl: '', diff --git a/scripts/branding-guard.ts b/scripts/branding-guard.ts index 5ef04c6065..05cda5cba2 100644 --- a/scripts/branding-guard.ts +++ b/scripts/branding-guard.ts @@ -13,6 +13,7 @@ import { joinBrandUrl, DEFAULT_BRAND_CONFIG, } from '../libraries/helpers/src/utils/brand.config'; +import { getMobileAppScheme } from '../libraries/helpers/src/utils/mobile.app.scheme'; import { readdirSync, readFileSync, statSync } from 'fs'; import { join, extname } from 'path'; @@ -120,6 +121,20 @@ console.log('=== Running Branding Guard Validations ===\n'); assert(joinBrandUrl(undefined, undefined) === '', 'joinBrandUrl with nothing returns an empty string rather than "undefined"'); } +// 2d. getMobileAppScheme. The backend redirects a live OAuth authorization +// code to this scheme, so an unset variable must yield '' (callers then refuse +// or omit the deep link) and never silently fall back to an upstream scheme. +{ + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: 'crove://auth/callback' }) === 'crove://', 'getMobileAppScheme extracts the scheme prefix from a full callback URL'); + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: 'postiz://auth/callback' }) === 'postiz://', 'getMobileAppScheme returns whatever scheme is configured, without hardcoding one'); + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: '' }) === '', 'getMobileAppScheme returns empty for an unset variable so the mobile callback fails closed'); + assert(getMobileAppScheme({}) === '', 'getMobileAppScheme returns empty when the variable is absent'); + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: ' ' }) === '', 'getMobileAppScheme returns empty for whitespace-only input'); + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: 'not-a-scheme' }) === '', 'getMobileAppScheme rejects a value with no scheme separator'); + assert(getMobileAppScheme({ MOBILE_APP_SCHEME: 'javascript://alert(1)' }) === 'javascript://', 'getMobileAppScheme only parses the prefix; callers must not treat it as a safe URL'); + assert(getMobileAppScheme({ NEXT_PUBLIC_MOBILE_APP_SCHEME: 'crove://cb' }) === 'crove://', 'getMobileAppScheme honours the NEXT_PUBLIC_ alias'); +} + // 3. Security sanitization tests { assert(sanitizeUrl('javascript:alert(1)') === undefined, 'Reject javascript: URLs'); From 2a3dd5c55250338aeac9fa4bdcf7a3667534fcbb Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:58:10 +0700 Subject: [PATCH 4/8] feat(ops): probe the deployed MCP surface from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3 — verifying the MCP server actually answers with a real credential — could not be done from a dev machine: DNS to the deployed domains is unavailable there, and booting the full backend locally needs Temporal plus its own Postgres and Elasticsearch. GitHub Actions runners have public internet access, so the probe runs from there instead, for production and beta. It asserts: - RFC 9728 discovery is served and names the correct protected resource - an unauthenticated initialize is refused with a real WWW-Authenticate header carrying resource_metadata — not the swallowed, empty 401 shape that made DOSClaw misread the 2026-09-08 database outage as an OAuth failure - an unknown bearer token is rejected as invalid_token (fail closed) - /mcp/:id with a bogus key answers the distinct 400 'Invalid API Key' - when the MCP_PROBE_TOKEN secret is set: initialize with a pos_ token, a serverInfo.name that is branded and never 'Postiz MCP', tools/list, and public API is-connected — the exact route DOSClaw hit in that incident All requests are read-only (discovery, initialize, tools/list), so it is safe on a 30-minute schedule. It is deliberately not a push/PR gate: a transient production blip must not fail unrelated work. Set the MCP_PROBE_TOKEN repository secret to upgrade from "the surface fails closed" to "a real credential completes the agent path". --- .github/workflows/mcp-surface-probe.yml | 56 +++++++ scripts/probe-mcp.mjs | 209 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 .github/workflows/mcp-surface-probe.yml create mode 100644 scripts/probe-mcp.mjs diff --git a/.github/workflows/mcp-surface-probe.yml b/.github/workflows/mcp-surface-probe.yml new file mode 100644 index 0000000000..b3211bf4b2 --- /dev/null +++ b/.github/workflows/mcp-surface-probe.yml @@ -0,0 +1,56 @@ +--- +name: MCP Surface Probe + +# Runs the read-only MCP surface probe (scripts/probe-mcp.mjs) against +# production and beta. GitHub runners have public internet access, so this is +# the only place the deployed endpoints can be checked from CI — it turns the +# 2026-09-08 incident (a database outage swallowed into an empty 401, which +# DOSClaw misread as an OAuth failure) into an alarm instead of a silent outage. +# +# Every rejection asserted here must be well-formed and intentional. A generic +# 401, a 5xx or a timeout fails the run, which is the point. +# +# To also assert the authenticated agent path (initialize with a pos_ token, +# tools/list, public API is-connected), set the MCP_PROBE_TOKEN repository +# secret. Without it the probe still verifies every endpoint fails closed. +# +# All requests are read-only, so scheduling it is safe. It is deliberately NOT +# a push/PR gate: a transient production blip should not fail unrelated work. + +on: + workflow_dispatch: + schedule: + # Every 30 minutes, deliberately off the :00/:30 grid. + - cron: '13,43 * * * *' + +permissions: + contents: read + +jobs: + probe: + name: "Probe ${{ matrix.target }}" + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + # One environment being down must not hide the state of the other. + fail-fast: false + matrix: + include: + - target: production + url: https://post.crove.com/api + - target: beta + url: https://beta-post.crove.com/api + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Probe MCP surface + env: + MCP_PROBE_TOKEN: ${{ secrets.MCP_PROBE_TOKEN }} + run: node scripts/probe-mcp.mjs ${{ matrix.url }} ${{ matrix.target }} diff --git a/scripts/probe-mcp.mjs b/scripts/probe-mcp.mjs new file mode 100644 index 0000000000..0b47530f4d --- /dev/null +++ b/scripts/probe-mcp.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Probe the MCP surface of a deployed Crove Post instance. + * + * Usage: node scripts/probe-mcp.mjs