Skip to content
Open
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
32 changes: 22 additions & 10 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
# Terminal49 MCP Server - Environment Variables
# Copy this file to .env.local and fill in your credentials

# Terminal49 API Token
# Get your token from: https://app.terminal49.com/settings/api
T49_API_TOKEN=your_api_token_here

# Terminal49 API Base URL (optional)
# Default: https://api.terminal49.com/v2
T49_API_BASE_URL=https://api.terminal49.com/v2
# Terminal49 MCP Server - Environment Variables
# Copy this file to .env.local and fill in your credentials

# Legacy compatibility auth (still supported during migration)
# Get your token from: https://app.terminal49.com/settings/api
T49_API_TOKEN=your_api_token_here

# Upstream Terminal49 API base URL (optional)
# Default: https://api.terminal49.com/v2
T49_API_BASE_URL=https://api.terminal49.com/v2

# OAuth challenge metadata URL returned via WWW-Authenticate
T49_MCP_RESOURCE_METADATA_URL=https://api.terminal49.com/.well-known/oauth-authorization-server

# Internal fallback token verification endpoint (server-to-server)
T49_MCP_TOKEN_VERIFY_URL=https://api.terminal49.com/internal/mcp/token_principal
T49_MCP_INTERNAL_AUTH_TOKEN=replace-with-shared-internal-secret

# WorkOS MCP JWT verification settings
WORKOS_MCP_ISSUER=https://api.workos.com
WORKOS_MCP_AUDIENCE=replace-with-your-workos-audience
WORKOS_MCP_JWKS_URL=https://api.workos.com/sso/jwks/your-jwks-id
91 changes: 83 additions & 8 deletions api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { looksLikeJwt, verifyWorkosJwt } from '../packages/mcp/src/auth/workos-jwt.js';
import { createTerminal49McpServer } from '../packages/mcp/src/server.js';

type RequestLike = {
Expand Down Expand Up @@ -42,14 +43,18 @@ function getHeaderValue(value: string | string[] | undefined): string | undefine

function extractAuthorizationToken(
authorizationHeader: string | undefined,
): { token?: string; source?: 'authorization' } {
): { token?: string; source?: 'authorization'; scheme?: 'bearer' | 'token' } {
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) {
return { token, source: 'authorization' };
return {
token,
source: 'authorization',
scheme: authMatch[1]?.toLowerCase() === 'bearer' ? 'bearer' : 'token',
};
}
}
}
Expand Down Expand Up @@ -176,6 +181,57 @@ function validateRequestSecurity(req: RequestLike, res: ResponseLike): boolean {
return true;
}

function oauthResourceMetadataUrl(): string {
return (
process.env.T49_MCP_RESOURCE_METADATA_URL?.trim() ??
'https://api.terminal49.com/.well-known/oauth-authorization-server'
);
}

function setOAuthChallengeHeader(res: ResponseLike): void {
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${oauthResourceMetadataUrl()}"`);
}

function unauthorized(res: ResponseLike, payload: { error: string; message: string }): void {
setCorsHeaders(res);
setOAuthChallengeHeader(res);
res.status(401).json(payload);
}

async function verifyViaInternalPrincipal(token: string): Promise<boolean> {
const verifyUrl = process.env.T49_MCP_TOKEN_VERIFY_URL?.trim();
const internalAuthToken = process.env.T49_MCP_INTERNAL_AUTH_TOKEN?.trim();
if (!verifyUrl || !internalAuthToken) {
return false;
}

const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), 2000);

try {
const response = await fetch(verifyUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${internalAuthToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
signal: abortController.signal,
});

if (!response.ok) {
return false;
}

const payload = (await response.json()) as { active?: boolean; user_id?: string; account_id?: string };
return Boolean(payload?.active && payload?.user_id && payload?.account_id);
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}

/**
* Main handler for Vercel serverless function
*/
Expand Down Expand Up @@ -259,10 +315,10 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom
const authHeader = getHeaderValue(req.headers.authorization);
const resolvedAuth = extractAuthorizationToken(authHeader);
const callerToken = resolvedAuth.token;
const callerScheme = resolvedAuth.scheme;

if (!callerToken) {
setCorsHeaders(res);
res.status(401).json({
unauthorized(res, {
error: 'Unauthorized',
message:
'Missing valid Authorization header. Use `Authorization: Bearer <token>` or `Authorization: Token <token>`.',
Expand All @@ -274,9 +330,29 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom
const configuredApiToken = process.env.T49_API_TOKEN?.trim();
const configuredClientSecret = process.env.T49_MCP_CLIENT_SECRET?.trim();
let apiToken = callerToken;
let authSource: 'authorization' | 'environment' = resolvedAuth.source ?? 'authorization';
let authSource: 'authorization' | 'environment' | 'oauth_local' | 'oauth_remote' =
resolvedAuth.source ?? 'authorization';

const jwtLikeBearerToken = callerScheme === 'bearer' && looksLikeJwt(callerToken);
if (jwtLikeBearerToken) {
const localVerificationPayload = await verifyWorkosJwt(callerToken);
if (localVerificationPayload) {
apiToken = `Bearer ${callerToken}`;
authSource = 'oauth_local';
} else if (await verifyViaInternalPrincipal(callerToken)) {
apiToken = `Bearer ${callerToken}`;
authSource = 'oauth_remote';
} else {
unauthorized(res, {
error: 'Unauthorized',
message: 'Invalid OAuth bearer token.',
});
logLifecycle('mcp.request.complete', requestId, { reason: 'invalid_oauth_bearer' });
return;
}
}

if (configuredApiToken) {
if (configuredApiToken && authSource !== 'oauth_local' && authSource !== 'oauth_remote') {
if (!configuredClientSecret) {
setCorsHeaders(res);
res.status(500).json({
Expand All @@ -288,8 +364,7 @@ export default async function handler(req: RequestLike, res: ResponseLike): Prom
}

if (!isMatchingClientSecret(callerToken, configuredClientSecret)) {
setCorsHeaders(res);
res.status(401).json({
unauthorized(res, {
error: 'Unauthorized',
message: 'Invalid client credentials.',
});
Expand Down
5 changes: 5 additions & 0 deletions docs/api-docs/api-reference/search/search.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
og:title: Search | Terminal49 API Documentation
og:description: Full-text search across shipments, containers, and tracking requests using Terminal49's API.
openapi: get /search
---
23 changes: 15 additions & 8 deletions docs/api-docs/in-depth-guides/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Before you begin, make sure you have:
<Card title="Terminal49 Account" icon="user">
A Terminal49 account with API access
</Card>
<Card title="API Token" icon="key">
A `T49_API_TOKEN` from the [dashboard](https://app.terminal49.com/developers/api-keys)
<Card title="OAuth Client Setup" icon="key">
Discovery + registration + browser auth for hosted MCP (preferred)
</Card>
<Card title="Node.js 18+" icon="node-js">
Required if running the MCP server locally
Expand All @@ -43,23 +43,30 @@ Before you begin, make sure you have:
|-----------|----------|----------|
| HTTP (streamable) | `POST /api/mcp` or `POST /mcp` | Serverless, short-lived requests |

**Authentication**: API token only (OAuth not required for this release).
Pass `Authorization: Bearer <T49_API_TOKEN>` or `Authorization: Token <T49_API_TOKEN>`.
Server falls back to `T49_API_TOKEN` environment variable.
**Authentication**: OAuth 2.1 for hosted MCP (preferred).
Discover OAuth metadata from `GET /.well-known/oauth-authorization-server`, register via `POST /oauth/register`, then complete auth and call MCP with bearer tokens.
On unauthenticated requests, server returns `401` with:
`WWW-Authenticate: Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"`.

Legacy compatibility mode still accepts:
`Authorization: Bearer <T49_API_TOKEN>` or `Authorization: Token <T49_API_TOKEN>`.

<Tip>
Claude Desktop and Cursor use the HTTP transport. For hosted production usage, use Streamable HTTP at `/mcp`.
</Tip>

<Note>
For hosted OAuth implementation planning, see [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) and [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan).
For hosted OAuth requirements and validation:
- [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements)
- [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan)
- [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke)
</Note>

---

## Configure Your MCP Client

### Claude Desktop
### Claude Desktop (legacy compatibility example)

<Tabs>
<Tab title="macOS">
Expand Down Expand Up @@ -112,7 +119,7 @@ For hosted OAuth implementation planning, see [Hosted HTTP OAuth Requirements](/
</Tab>
</Tabs>

### Cursor IDE
### Cursor IDE (legacy compatibility example)

Add to your Cursor settings:

Expand Down
9 changes: 8 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@
"api-docs/api-reference/containers/get-a-containers-transport-events"
]
},
{
"group": "Search",
"icon": "magnifying-glass",
"pages": [
"api-docs/api-reference/search/search"
]
},
{
"group": "Webhooks",
"icon": "webhook",
Expand Down Expand Up @@ -357,4 +364,4 @@
"og:site_name": "Terminal49 Docs"
}
}
}
}
26 changes: 19 additions & 7 deletions docs/mcp/home.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ Use the Terminal49 MCP server to let Claude or Cursor answer questions with live
## TL;DR – Get Started in 5 Minutes

<Steps>
<Step title="Get your API token">
Go to the [Terminal49 dashboard](https://app.terminal49.com/developers/api-keys) → Settings → API Tokens and create a `T49_API_TOKEN`.
<Step title="Choose auth mode">
Preferred: OAuth 2.1 (discovery + registration + browser authorization).
Compatibility: API token header is still supported during migration.
</Step>
<Step title="Pick your MCP client">
- **Claude Desktop** (macOS / Windows / Linux)
- **Cursor IDE**
</Step>
<Step title="Paste this config (Claude Desktop)">
<Step title="Paste config (legacy compatibility)">
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
Expand Down Expand Up @@ -59,6 +60,10 @@ Use the Terminal49 MCP server to let Claude or Cursor answer questions with live
</Step>
</Steps>

<Note>
For OAuth-native hosted setup and validation, use [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke).
</Note>

<Note>
Need test container numbers? See [Test Numbers](/api-docs/useful-info/test-numbers) for containers you can use during development.
</Note>
Expand All @@ -74,16 +79,23 @@ For the full walkthrough (including local stdio dev, deployment, and SDK example
| HTTP (streamable) | `POST /api/mcp` or `POST /mcp` | Serverless, short-lived requests |

**Authentication**:
- API token only (OAuth not required for this release)
- Header: `Authorization: Bearer <T49_API_TOKEN>` or `Authorization: Token <T49_API_TOKEN>`
- Or set `T49_API_TOKEN` environment variable when self-hosting
- OAuth 2.1 for hosted MCP (preferred)
- Discovery: `GET /.well-known/oauth-authorization-server`
- Dynamic registration: `POST /oauth/register` (public clients, no secret)
- On unauthenticated calls, MCP responds with:
`WWW-Authenticate: Bearer resource_metadata="https://api.terminal49.com/.well-known/oauth-authorization-server"`
- Legacy compatibility mode still accepts API tokens during migration:
`Authorization: Bearer <T49_API_TOKEN>` or `Authorization: Token <T49_API_TOKEN>`

<Tip>
Claude Desktop and Cursor use the HTTP transport at `https://mcp.terminal49.com/mcp`.
</Tip>

<Note>
Planning OAuth for hosted MCP? Use the [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements) and [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan).
For implementation details and validation, use:
- [Hosted HTTP OAuth Requirements](/mcp/hosted-http-oauth-requirements)
- [Hosted HTTP OAuth Test Plan](/mcp/hosted-http-oauth-test-plan)
- [OAuth E2E Smoke Test](/mcp/oauth-e2e-smoke)
</Note>

The same [rate limits](/api-docs/in-depth-guides/rate-limiting) apply to MCP endpoints as the REST API.
Expand Down
Loading
Loading