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
46 changes: 30 additions & 16 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ on:
branches: [main, master, feature/**]

jobs:
# Consumer compatibility: the standalone SDK lockfile on every supported
# Node version, with no workspace tooling involved.
sdk:
runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }}
strategy:
Expand All@@ -23,28 +25,38 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: sdks/typescript-sdk/package-lock.json
- name: Install SDK deps
- name: Install standalone SDK deps
run: npm ci
- name: Build SDK
run: npm run build
- name: Type-check SDK
run: npm run type-check
- name: Test SDK
run: npm test -- --run

# Development tooling: root workspace lockfile on the development runtime.
# Lint/format/docs are static checks, so one modern Node version suffices.
sdk-tooling:
runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }}
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 deps
run: npm ci
- name: Build SDK
run: npm run build --workspace @terminal49/sdk
- name: Generate SDK docs
if: matrix.node-version == 24
run: npm run docs
run: npm run docs --workspace @terminal49/sdk
- name: Check generated SDK docs are up to date
if: matrix.node-version == 24
working-directory: ${{ github.workspace }}
run: |
git diff --exit-code -- docs/sdk/reference
test -z "$(git status --porcelain -- docs/sdk/reference)"
- name: Test SDK
run: npm test -- --run
# oxlint/oxfmt require Node 20+; lint is static so it only needs one
# modern runtime. Build/test still cover the full matrix for consumers.
- name: Lint SDK (oxlint + oxfmt)
if: matrix.node-version == 24
run: npm run lint
- name: Check SDK (Vite+ and anti-slop)
run: npm run lint --workspace @terminal49/sdk

cli:
runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }}
Expand All@@ -69,9 +81,9 @@ jobs:
run: npm run type-check --workspace @terminal49/cli
- name: Test CLI
run: npm run test --workspace @terminal49/cli
# oxlint/oxfmt require Node 20+; lint is static so it only needs one
# modern runtime. Build/test still cover the full matrix for consumers.
- name: Lint CLI (oxlint + oxfmt)
# Vite+ linting is static, so it only needs the development runtime.
# Build/test still cover the full matrix for consumers.
- name: Check CLI (Vite+ and anti-slop)
if: matrix.node-version == 24
run: npm run lint --workspace @terminal49/cli

Expand DownExpand Up@@ -102,5 +114,7 @@ jobs:
env:
MCP_EVAL_TOKEN: ${{ secrets.MCP_EVAL_TOKEN }}
run: npm run eval --workspace @terminal49/mcp
- name: Lint MCP (oxlint + oxfmt)
- name: Check MCP (Vite+ and anti-slop)
run: npm run lint --workspace @terminal49/mcp
- name: Check API gateway (Vite+ and anti-slop)
run: npm run lint:api
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ This repo is **not docs-only**. It contains three things that ship independently
2. **The Terminal49 MCP server + OAuth gateway** (`api/`, `packages/mcp/`) — deployed to **mcp.terminal49.com** on Vercel.
3. **The Terminal49 TypeScript SDK** (`sdks/typescript-sdk/`) — published to npm as `@terminal49/sdk`.

It is an **npm workspaces monorepo** (`packages/*`, `sdks/*`), Node 24. The root `package-lock.json` is the authoritative lockfile; `npm ci` (root) and Vercel both install from it.
It is an **npm workspaces monorepo** (`packages/*`, `sdks/*`), Node 24. Vite+ is the development toolchain and delegates package installation to npm. The root `package-lock.json` is the authoritative workspace lockfile; `vp install` (root) and Vercel both install from it.

> `CLAUDE.md` / `claude.md` are symlinks to this file. Edit `AGENTS.md` to change agent instructions.

Expand DownExpand Up@@ -37,7 +37,7 @@ For substantive documentation writing, use the repo-local skill at `skills/termi
- `src/server.ts` — `createTerminal49McpServer()`, built on `@modelcontextprotocol/sdk` (`McpServer`, `registerTool`/`registerResource`). Used by both the stdio entry (`src/index.ts`) and the `api/` HTTP gateway.
- `src/resource.ts` — **single source of truth** for the OAuth `resource` identifier. Both the PRM endpoint and the `WWW-Authenticate` challenge resolve through it so they can never diverge (RFC 9728). Do not reintroduce per-file resource derivation.
- `src/tools/`, `src/resources/` — MCP tools and resources.
- `tests/`, `src/**/*.test.ts` — vitest.
- `tests/`, `src/**/*.test.ts` — Vitest through Vite+.

### TypeScript SDK (`sdks/typescript-sdk/`) — `@terminal49/sdk`
- `src/` — the client (JSON:API, openapi-fetch). `src/generated/**` is generated — **do not hand-edit**.
Expand All@@ -63,10 +63,11 @@ For substantive documentation writing, use the repo-local skill at `skills/termi
- Regenerate Postman: `openapi2postmanv2 -s docs/openapi.json -o Terminal49-API.postman_collection.json -p -O folderStrategy=Tags`

### Code (npm workspaces)
- Install: `npm ci` (root)
- Test: `npm run test --workspace @terminal49/mcp -- --run` · `npm run test --workspace @terminal49/sdk -- --run` (vitest)
- Install: `vp install` (root; use `npm ci` for frozen CI installs)
- Test: `npm run test --workspace @terminal49/mcp` · `npm run test --workspace @terminal49/sdk` (Vite+ / Vitest)
- Typecheck / build: `npm run build --workspace @terminal49/mcp` · `--workspace @terminal49/sdk` (tsc). `api/` is typechecked by the root config: `npx tsc --noEmit -p tsconfig.json`.
- **Lint/format: oxlint + oxfmt** (migrated off Biome). `npm run lint --workspace <pkg>`; auto-format with `npm run format --workspace <pkg>` (oxfmt). Config: `.oxlintrc.json` + `.oxfmtrc.json` per package. The SDK lint also runs `oxfmt --check`; MCP is lint-only.
- **Lint/format: Vite+** (Oxlint + Oxfmt with vendored anti-slop rules). `npm run lint --workspace <pkg>`; auto-format with `npm run format --workspace <pkg>`; the `api/` gateway is covered by root `npm run lint:api` / `npm run format`. All configuration lives in the **root `vite.config.ts`** — Vite+ resolves only the root config in a monorepo, so do not add workspace-level `vite.config.ts` files (they are silently ignored). The vendored plugin is in `tools/oxlint/anti-slop/` (see its README for local modifications to re-apply when re-vendoring).
- **Toolchain version coupling** (root `package.json`): the `vite` override alias (`npm:@voidzero-dev/vite-plus-core`), the exact `vitest` pin, the `@vitest/coverage-v8` devDeps in workspaces, and `@oxlint/plugins` must all move in lockstep with the `vite-plus` version — do not bump any of them independently.
- CI (`.github/workflows/ci.yml`) runs build + test + lint for both packages.
- Running the MCP server locally + testing tool calls with Claude Desktop (stdio and gateway paths): [packages/mcp/LOCAL_DEV.md](packages/mcp/LOCAL_DEV.md). Gateway env template: `.env.local.example`.

Expand Down
98 changes: 73 additions & 25 deletions api/mcp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
type ResponseLike = {
headersSent: boolean;
status(code: number): ResponseLike;
json(payload: unknown): void;

Check warning on line 27 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

anti-slop(no-unknown-parameters)

api/mcp.ts:27: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@@ -39,30 +39,35 @@
);
}

function getHeaderValue(value: string | string[] | undefined): string | undefined {
function getHeaderValue(
value: string | string[] | undefined,
): string | undefined {
if (Array.isArray(value)) {
return value[0];
}

return value;
}

function extractAuthorizationToken(
authorizationHeader: string | undefined,
): { scheme?: 'Bearer' | 'Token'; token?: string; source?: 'authorization' } {
function extractAuthorizationToken(authorizationHeader: string | undefined): {
scheme?: 'Bearer' | 'Token';
token?: string;
source?: 'authorization';
} {
if (authorizationHeader?.trim()) {
const trimmed = authorizationHeader.trim();
const authMatch = trimmed.match(/^(bearer|token)\s+(.+)$/i);
if (authMatch?.[2]) {
const token = authMatch[2].trim();
if (token.length > 0) {
const scheme = authMatch[1].toLowerCase() === 'bearer' ? 'Bearer' : 'Token';
const scheme =
authMatch[1].toLowerCase() === 'bearer' ? 'Bearer' : 'Token';
return { scheme, token, source: 'authorization' };

Check warning on line 65 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

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

api/mcp.ts:65: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 70 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

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

api/mcp.ts:70: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 All@@ -85,11 +90,15 @@

function oauthConfigured(): boolean {
return Boolean(
process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() || process.env.WORKOS_ISSUER?.trim(),
process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() ||
process.env.WORKOS_ISSUER?.trim(),
);
}

function wwwAuthenticateHeader(req: RequestLike, reason: UnauthorizedReason): string {
function wwwAuthenticateHeader(
req: RequestLike,
reason: UnauthorizedReason,
): string {
const parts = ['Bearer realm="mcp"'];

// RFC 6750 §3.1: include an error code only when a token was actually
Expand DownExpand Up@@ -124,7 +133,8 @@
}

function resolveEndpointUrl(): string {
const apiBaseUrl = process.env.T49_API_BASE_URL?.trim() || 'https://api.terminal49.com/v2';
const apiBaseUrl =
process.env.T49_API_BASE_URL?.trim() || 'https://api.terminal49.com/v2';
return `${apiBaseUrl.replace(/\/+$/, '')}/connected-clients/resolve`;
}

Expand All@@ -144,7 +154,8 @@
token: string,
requestId: string,
): Promise<{ apiToken: string; accountId: string }> {
const resolveSecret = process.env.T49_CONNECTED_CLIENTS_RESOLVE_SECRET?.trim() ||
const resolveSecret =
process.env.T49_CONNECTED_CLIENTS_RESOLVE_SECRET?.trim() ||
process.env.T49_MCP_RESOLVE_SECRET?.trim();
if (!resolveSecret) {
throw new ConnectedClientResolveError(
Expand All@@ -166,14 +177,14 @@
});
} catch (error) {
throw new ConnectedClientResolveError(
`Terminal49 connected client resolve request failed: ${(error as Error).message}`,

Check warning on line 180 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

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

api/mcp.ts:180: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 187 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

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

api/mcp.ts:187:15: This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.
} catch {
payload = {};
}
Expand All@@ -184,9 +195,12 @@
// surface that as retryable so clients don't discard a valid token and loop
// through re-authentication during a Terminal49 outage.
const kind: ResolveFailureKind =
response.status === 401 || response.status === 403 ? 'invalid_token' : 'upstream';
response.status === 401 || response.status === 403
? 'invalid_token'
: 'upstream';
throw new ConnectedClientResolveError(
payload.error || `Terminal49 connected client resolve failed with ${response.status}`,
payload.error ||
`Terminal49 connected client resolve failed with ${response.status}`,
kind,
);
}
Expand All@@ -203,7 +217,10 @@
return { apiToken: `Bearer ${accessToken}`, accountId };
}

function isMatchingClientSecret(providedToken: string, expectedSecret: string): boolean {
function isMatchingClientSecret(
providedToken: string,
expectedSecret: string,
): boolean {
const providedBuffer = Buffer.from(providedToken);
const expectedBuffer = Buffer.from(expectedSecret);

Expand All@@ -223,7 +240,11 @@
return randomUUID();
}

function logLifecycle(event: string, requestId: string, details: Record<string, unknown> = {}): void {
function logLifecycle(
event: string,
requestId: string,
details: Record<string, unknown> = {},

Check warning on line 246 in api/mcp.ts

View workflow job for this annotation

GitHub Actions/ mcp

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

api/mcp.ts:246: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 {
console.error(
JSON.stringify({
event,
Expand All@@ -247,7 +268,10 @@
);
}

function isAllowedHost(hostHeader: string | undefined, allowList: Set<string>): boolean {
function isAllowedHost(
hostHeader: string | undefined,
allowList: Set<string>,
): boolean {
if (allowList.size === 0) {
return true;
}
Expand DownExpand Up@@ -325,13 +349,20 @@
/**
* Main handler for Vercel serverless function
*/
export default async function handler(req: RequestLike, res: ResponseLike): Promise<void> {
export default async function handler(
req: RequestLike,
res: ResponseLike,
): Promise<void> {
const requestId = buildRequestId(req);
setCorsHeaders(res);
logLifecycle('mcp.request.start', requestId, { method: req.method ?? 'UNKNOWN' });
logLifecycle('mcp.request.start', requestId, {
method: req.method ?? 'UNKNOWN',
});

if (!validateRequestSecurity(req, res)) {
logLifecycle('mcp.request.rejected', requestId, { reason: 'request_security_validation_failed' });
logLifecycle('mcp.request.rejected', requestId, {
reason: 'request_security_validation_failed',
});
return;
}

Expand All@@ -349,7 +380,10 @@
error: 'Method not allowed',
message: 'Only POST requests are accepted',
});
logLifecycle('mcp.request.complete', requestId, { reason: 'method_not_allowed', method: req.method });
logLifecycle('mcp.request.complete', requestId, {
reason: 'method_not_allowed',
method: req.method,
});
return;
}

Expand All@@ -371,7 +405,7 @@
try {
await transport.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}`);
}
}
Expand All@@ -386,7 +420,10 @@
}

if (cleanupErrors.length > 0) {
logLifecycle('mcp.request.cleanup.error', requestId, { reason, errors: cleanupErrors });
logLifecycle('mcp.request.cleanup.error', requestId, {
reason,
errors: cleanupErrors,
});
return;
}

Expand DownExpand Up@@ -415,7 +452,9 @@
message:
'Missing valid Authorization header. Use `Authorization: Bearer <token>` or `Authorization: Token <token>`.',
});
logLifecycle('mcp.request.complete', requestId, { reason: 'missing_authorization' });
logLifecycle('mcp.request.complete', requestId, {
reason: 'missing_authorization',
});
return;
}

Expand All@@ -433,7 +472,10 @@
// packages/mcp/WORKOS_MCP_SETUP.md (rollout note).
if (authKitMcpEnabled() && resolvedAuth.scheme === 'Bearer') {
try {
const resolved = await resolveConnectedClientToken(callerToken, requestId);
const resolved = await resolveConnectedClientToken(
callerToken,
requestId,
);
resolvedTerminal49Auth = {
apiToken: resolved.apiToken,
accountId: resolved.accountId,
Expand DownExpand Up@@ -467,7 +509,8 @@
// their token is bad and triggers re-auth loops. 502 invites a retry.
res.status(502).json({
error: 'Bad Gateway',
message: 'Authorization service is temporarily unavailable. Please retry.',
message:
'Authorization service is temporarily unavailable. Please retry.',
});
}
return;
Expand All@@ -477,9 +520,12 @@
setCorsHeaders(res);
res.status(500).json({
error: 'Server misconfiguration',
message: 'T49_MCP_CLIENT_SECRET must be set when T49_API_TOKEN is configured.',
message:
'T49_MCP_CLIENT_SECRET must be set when T49_API_TOKEN is configured.',
});
logLifecycle('mcp.request.complete', requestId, {
reason: 'missing_client_secret',
});
logLifecycle('mcp.request.complete', requestId, { reason: 'missing_client_secret' });
return;
}

Expand All@@ -490,7 +536,9 @@
error: 'Unauthorized',
message: 'Invalid client credentials.',
});
logLifecycle('mcp.request.complete', requestId, { reason: 'invalid_client_secret' });
logLifecycle('mcp.request.complete', requestId, {
reason: 'invalid_client_secret',
});
return;
}

Expand Down
7 changes: 5 additions & 2 deletions api/oauth-authorization-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@

type ResponseLike = {
status(code: number): ResponseLike;
json(payload: unknown): void;

Check warning on line 9 in api/oauth-authorization-server.ts

View workflow job for this annotation

GitHub Actions/ mcp

anti-slop(no-unknown-parameters)

api/oauth-authorization-server.ts:9: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;
} & ServerResponse;
Expand DownExpand Up@@ -40,11 +40,14 @@
return;
}

const authorizationServer = process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() ||
const authorizationServer =
process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() ||
process.env.WORKOS_ISSUER?.trim();

if (!authorizationServer) {
res.status(500).json({ error: 'WORKOS_AUTHORIZATION_SERVER_URL or WORKOS_ISSUER must be set.' });
res.status(500).json({
error: 'WORKOS_AUTHORIZATION_SERVER_URL or WORKOS_ISSUER must be set.',
});
return;
}

Expand Down
11 changes: 8 additions & 3 deletions api/oauth-protected-resource.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@

type ResponseLike = {
status(code: number): ResponseLike;
json(payload: unknown): void;

Check warning on line 10 in api/oauth-protected-resource.ts

View workflow job for this annotation

GitHub Actions/ mcp

anti-slop(no-unknown-parameters)

api/oauth-protected-resource.ts:10: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;
} & ServerResponse;
Expand All@@ -27,11 +27,14 @@
return;
}

const authorizationServer = process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() ||
const authorizationServer =
process.env.WORKOS_AUTHORIZATION_SERVER_URL?.trim() ||
process.env.WORKOS_ISSUER?.trim();

if (!authorizationServer) {
res.status(500).json({ error: 'WORKOS_AUTHORIZATION_SERVER_URL or WORKOS_ISSUER must be set.' });
res.status(500).json({
error: 'WORKOS_AUTHORIZATION_SERVER_URL or WORKOS_ISSUER must be set.',
});
return;
}

Expand All@@ -48,6 +51,8 @@
resource: resolveMcpResource(req),
authorization_servers: [authorizationServer.replace(/\/+$/, '')],
bearer_methods_supported: ['header'],
...(scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {}),
...(scopesSupported.length > 0
? { scopes_supported: scopesSupported }
: {}),

Check warning on line 56 in api/oauth-protected-resource.ts

View workflow job for this annotation

GitHub Actions/ mcp

anti-slop(no-conditional-empty-object-spread)

api/oauth-protected-resource.ts:54:5: This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.
});
}
Loading
Loading