Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
pull_request:
branches: [main, master, feature/**]

permissions:
contents: read
pull-requests: read
statuses: read

jobs:
# Consumer compatibility: the standalone SDK lockfile on every supported
# Node version, with no workspace tooling involved.
Expand Down Expand Up @@ -118,3 +123,140 @@ jobs:
run: npm run lint --workspace @terminal49/mcp
- name: Check API gateway (Vite+ and anti-slop)
run: npm run lint:api

mcp-protocol-compat:
name: MCP protocol ${{ matrix.protocol-version }}
runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }}
strategy:
fail-fast: false
matrix:
protocol-version:
- '2026-07-28'
- '2025-11-25'
- '2025-06-18'
- '2025-03-26'
- '2024-11-05'
- '2024-10-07'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install workspace dependencies
run: npm ci
- name: Build SDK dependency
run: npm run build --workspace @terminal49/sdk
- name: Build MCP server
run: npm run build --workspace @terminal49/mcp
- name: POST protocol handshake to built MCP server
env:
MCP_PROTOCOL_VERSION: ${{ matrix.protocol-version }}
run: npm run test:protocol --workspace @terminal49/mcp

mcp-preview-protocol:
name: MCP preview ${{ matrix.protocol-version }}
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
needs: [mcp, mcp-protocol-compat]
runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }}
strategy:
fail-fast: false
matrix:
protocol-version:
- '2026-07-28'
- '2025-11-25'
steps:
- name: Check preview credential availability
id: credential
env:
MCP_EVAL_TOKEN: ${{ secrets.MCP_EVAL_TOKEN }}
run: |
if [[ -n "$MCP_EVAL_TOKEN" ]]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping authenticated preview smoke because MCP_EVAL_TOKEN is unavailable"
fi
- uses: actions/checkout@v4
if: steps.credential.outputs.available == 'true'
- uses: actions/setup-node@v6
if: steps.credential.outputs.available == 'true'
with:
node-version: 24
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install workspace dependencies
if: steps.credential.outputs.available == 'true'
run: npm ci
- name: Wait for this commit's Vercel preview
if: steps.credential.outputs.available == 'true'
id: vercel
env:
GH_TOKEN: ${{ github.token }}
PREVIEW_SHA: ${{ github.event.pull_request.head.sha }}
run: |
state=pending
for attempt in {1..60}; do
state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${PREVIEW_SHA}/status" \
--jq '[.statuses[] | select(.context == "Vercel")][0].state // "pending"')"
if [[ "$state" == "success" ]]; then
break
fi
if [[ "$state" == "failure" || "$state" == "error" ]]; then
echo "Vercel preview failed for ${PREVIEW_SHA}"
exit 1
fi
sleep 10
done
if [[ "$state" != "success" ]]; then
echo "Timed out waiting for Vercel preview for ${PREVIEW_SHA}"
exit 1
fi
inspector_url="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${PREVIEW_SHA}/status" \
--jq '[.statuses[] | select(.context == "Vercel")][0].target_url // empty')"
if [[ -z "$inspector_url" ]]; then
echo "Vercel status for ${PREVIEW_SHA} has no deployment URL"
exit 1
fi
echo "inspector-url=${inspector_url}" >> "$GITHUB_OUTPUT"
- name: Resolve Vercel preview endpoint
if: steps.credential.outputs.available == 'true'
id: preview
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_INSPECTOR_URL: ${{ steps.vercel.outputs.inspector-url }}
run: |
preview_url="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: This selects the latest Vercel bot comment independently of PREVIEW_SHA. If commit B is pushed while commit A's workflow is still running, A can wait for its own successful deployment and then execute the smoke test against B's preview URL, allowing A's check to pass without exercising A's gateway. Resolve the deployment URL from PREVIEW_SHA, or verify the selected deployment's source SHA before testing it.

--paginate --slurp | jq -r --arg inspector "$VERCEL_INSPECTOR_URL" \
'flatten | [.[] | select((.user.login == "vercel[bot]" or .user.login == "vercel") and (.body | contains($inspector)))][-1].body // "" | (try capture("\\[Preview\\]\\((?<url>https://[^)]+\\.vercel\\.app)\\)") catch {}) | .url // empty')"
if [[ -z "$preview_url" ]]; then
echo "Could not resolve a Vercel preview URL for ${VERCEL_INSPECTOR_URL}"
exit 1
fi
echo "endpoint=${preview_url}/mcp" >> "$GITHUB_OUTPUT"
- name: POST handshake and tools/list to Vercel preview
if: steps.credential.outputs.available == 'true'
env:
MCP_HTTP_ENDPOINT: ${{ steps.preview.outputs.endpoint }}
MCP_HTTP_TOKEN: ${{ secrets.MCP_EVAL_TOKEN }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: MCP_EVAL_TOKEN is not exposed to pull_request workflows from forks or Dependabot, while http-protocol-smoke.mjs throws when MCP_HTTP_TOKEN is empty. Because this job runs for every pull request, valid external contributions will fail both matrix jobs. Gate this smoke check when the credential is unavailable, or move trusted preview validation to a protected follow-up workflow that does not execute untrusted PR code with secrets.

MCP_PROTOCOL_VERSION: ${{ matrix.protocol-version }}
run: npm run test:http-protocol --workspace @terminal49/mcp
- name: Verify preview still belongs to this commit
if: steps.credential.outputs.available == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
VERCEL_INSPECTOR_URL: ${{ steps.vercel.outputs.inspector-url }}
run: |
matches="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--paginate --slurp | jq -r --arg inspector "$VERCEL_INSPECTOR_URL" \
'flatten | [.[] | select((.user.login == "vercel[bot]" or .user.login == "vercel") and (.body | contains($inspector)))] | length')"
if [[ "$matches" != "1" ]]; then
echo "Vercel preview changed while the smoke test was running"
exit 1
fi
64 changes: 36 additions & 28 deletions api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
import '../packages/mcp/src/instrument.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
createMcpHandler,
type McpHttpHandler,
} from '@modelcontextprotocol/server';
import { toNodeHandler } from '@modelcontextprotocol/node';
import * as Sentry from '@sentry/node';
import { createTerminal49McpServer } from '../packages/mcp/src/server.js';
import { flushPostHogEvents } from '../packages/mcp/src/posthog.js';
Expand All @@ -26,7 +29,7 @@
type ResponseLike = {
headersSent: boolean;
status(code: number): ResponseLike;
json(payload: unknown): void;

Check warning on line 32 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(no-unknown-parameters)

api/mcp.ts:32:17: Parameter `payload` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.
setHeader(name: string, value: string): void;
end(): void;
on(event: 'close' | 'finish', listener: () => void): void;
Expand All @@ -37,7 +40,7 @@
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Session-Id',
'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id',
);
}

Expand All @@ -64,12 +67,12 @@
if (token.length > 0) {
const scheme =
authMatch[1].toLowerCase() === 'bearer' ? 'Bearer' : 'Token';
return { scheme, token, source: 'authorization' };

Check warning on line 70 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(no-known-value-widening)

api/mcp.ts:70:16: The explicit anonymous object type on return value of `extractAuthorizationToken` discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.
}
}
}

return {};

Check warning on line 75 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(no-known-value-widening)

api/mcp.ts:75:10: The explicit anonymous object type on return value of `extractAuthorizationToken` discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.
}

type ResolvedTerminal49Auth = {
Expand Down Expand Up @@ -179,14 +182,14 @@
});
} catch (error) {
throw new ConnectedClientResolveError(
`Terminal49 connected client resolve request failed: ${(error as Error).message}`,

Check warning on line 185 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(require-safety-comment-for-type-assertion)

api/mcp.ts:185:63: This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.
'upstream',
);
}

let payload: ConnectedClientResolutionResponse = {};
try {
payload = (await response.json()) as ConnectedClientResolutionResponse;

Check warning on line 192 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(require-safety-comment-for-type-assertion)

api/mcp.ts:192:15: This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.
} catch {
payload = {};
}
Expand Down Expand Up @@ -243,7 +246,7 @@
function logLifecycle(
event: string,
requestId: string,
details: Record<string, unknown> = {},

Check warning on line 249 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(no-unsafe-dictionary-type)

api/mcp.ts:249:12: This dictionary's unknown value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.
): void {
logMcpEvent({
event,
Expand Down Expand Up @@ -385,8 +388,7 @@
return;
}

let server: McpServer | undefined;
let transport: StreamableHTTPServerTransport | undefined;
let mcpHandler: McpHttpHandler | undefined;
let cleanupPromise: Promise<void> | null = null;
let shouldFlushSentry = false;

Expand All @@ -399,21 +401,12 @@
const cleanupErrors: string[] = [];
logLifecycle('mcp.request.cleanup.start', requestId, { reason });

if (transport?.close) {
if (mcpHandler) {
try {
await transport.close();
await mcpHandler.close();
} catch (error) {
const err = error as Error;

Check warning on line 408 in api/mcp.ts

View workflow job for this annotation

GitHub Actions / mcp

anti-slop(require-safety-comment-for-type-assertion)

api/mcp.ts:408:23: This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.
cleanupErrors.push(`transport.close: ${err.message}`);
}
}

if (server?.close) {
try {
await server.close();
} catch (error) {
const err = error as Error;
cleanupErrors.push(`server.close: ${err.message}`);
cleanupErrors.push(`handler.close: ${err.message}`);
}
}

Expand Down Expand Up @@ -552,15 +545,32 @@

setCorsHeaders(res);

// Create MCP server and per-request transport.
server = createTerminal49McpServer(
resolvedTerminal49Auth.apiToken,
process.env.T49_API_BASE_URL,
resolvedTerminal49Auth.accountId,
const observeMcpError = (error: Error): void => {
captureMcpException(error);
shouldFlushSentry = true;
logLifecycle('mcp.request.error', requestId, {
error: error.name,
message: error.message,
});
};

// The v2 HTTP entry serves the 2026-07-28 per-request protocol and keeps
// the established stateless 2025-era path for older clients.
mcpHandler = createMcpHandler(
() =>
createTerminal49McpServer(
resolvedTerminal49Auth.apiToken,
process.env.T49_API_BASE_URL,
resolvedTerminal49Auth.accountId,
),
{
legacy: 'stateless',
responseMode: 'json',
onerror: observeMcpError,
},
);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
enableJsonResponse: true, // Return JSON instead of SSE
const nodeHandler = toNodeHandler(mcpHandler, {
onerror: observeMcpError,
});

// Clean up on response lifecycle and also in finally to guarantee closure.
Expand All @@ -571,9 +581,7 @@
scheduleCleanup('response_finish');
});

// Connect server to transport and handle request
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
await nodeHandler(req, res, req.body);
logLifecycle('mcp.request.complete', requestId, { reason: 'handled' });
} catch (error) {
const err = error as Error;
Expand Down
Loading
Loading