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
180 changes: 180 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# AGENTS.md

Guidance for AI coding agents working in this repository.

## Project Overview

Remote MCP server for the Sevalla PaaS API (mcp.sevalla.com). Exposes ~200 API endpoints
through 2 tools (`search` + `execute`) using sandboxed V8 isolates. Built with Hono,
MCP SDK, and isolated-vm. No build step — TypeScript runs natively on Node.js 24+.

## Commands

```bash
pnpm dev # Development with --watch (native Node.js)
pnpm start # Run server (node src/index.ts)
pnpm test # Run all tests (node:test)
pnpm lint # oxlint
pnpm fmt # oxfmt (auto-fix)
pnpm fmt:check # oxfmt (check only)
pnpm check:code # Full check: tsc --noEmit --skipLibCheck && oxlint && oxfmt --check
```

### Running a Single Test

```bash
node --test test/oauth.test.ts # Run one test file
node --test --test-name-pattern="encrypt" test/oauth.test.ts # Run matching tests
```

The test runner is `node:test` (built-in). No Jest, Vitest, or Mocha.

### Type Checking

```bash
npx tsc --noEmit --skipLibCheck
```

## Node.js Version

**Node.js 24 required.** See `.nvmrc`. The `isolated-vm` native addon segfaults on Node 25.
TypeScript runs via Node's built-in type stripping — there is no build/compile step.

## Architecture

```
src/
├── index.ts # Main server: Hono app, MCP handler, graceful shutdown
├── oauth.ts # Stateless OAuth flow (HMAC-signed params, AES-256-GCM encrypted codes)
├── html.ts # Landing page HTML template
└── sandbox/
├── index.ts # Re-exports from all sandbox modules
├── bridge.ts # HTTP request bridge with security filters (path validation, header filtering)
├── isolate.ts # V8 isolate sandbox execution (isolated-vm)
├── spec.ts # OpenAPI spec processing ($ref resolution, tag extraction)
└── tools.ts # MCP tool definitions (search + execute)
```

Each HTTP POST to `/mcp` creates a fresh `McpServer` + `CodeMode` + transport bound to the
caller's API key. Fully stateless — no sessions, no server-side storage.

## Code Style

### Formatting (oxfmt — `.oxfmtrc.json`)

- **120 character** line width
- **Single quotes** — never double quotes
- **No semicolons**
- Enforced by `pnpm fmt` / `pnpm fmt:check`

### Linting (oxlint — `.oxlintrc.json`)

- Plugins: `unicorn`, `typescript`, `unused-imports`
- `prefer-const` enforced — never use `let` when `const` works
- `no-var` — never use `var`
- Unused variables prefixed with `_` (e.g., `_unused`)
- No unused imports
- No empty functions
- No `@ts-ignore` without a 10+ char explanation
- No non-null assertions (`!`)
- Use `Array<T>` syntax (not `T[]`) per `@typescript-eslint/array-type`
- Use `Record<K, V>` over index signatures per `consistent-indexed-object-style`
- Prefer `for...of` over indexed loops
- Prefer function type over interface with single call signature

### Functions

- **Always use arrow functions.** Never use `function` declarations.
- Export as `export const name = (...) => { ... }`

```typescript
// Correct
export const createThing = (input: string): Thing => { ... }

// Wrong — never do this
export function createThing(input: string): Thing { ... }
```

### Comments

- **No comments in code.** Code should be self-documenting. No TODO, FIXME, or inline
explanations. The only exception is fallback comments in catch blocks (e.g., `// fallback to raw text`).

### Imports

- **ESM only** (`"type": "module"` in package.json)
- Use `.ts` extensions in relative imports: `import { foo } from './bar.ts'`
- Use `node:` prefix for Node.js builtins: `import { randomBytes } from 'node:crypto'`
- Group imports: external packages first, then relative imports
- Use `type` imports when importing only types: `import type { Foo } from './bar.ts'`

```typescript
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createTools } from './sandbox/index.ts'
import type { BridgeRequest, RequestHandler } from './bridge.ts'
```

### Types and Interfaces

- TypeScript strict mode (`"strict": true`)
- Use `interface` for object shapes, `type` for unions/aliases
- Export types from barrel files (`sandbox/index.ts`)
- Use `Record<string, unknown>` over `object` or `any` where possible
- Zod (`zod` v4) for runtime input validation on tool schemas

### Naming Conventions

- `camelCase` for variables, functions, parameters
- `PascalCase` for types, interfaces
- `UPPER_SNAKE_CASE` for module-level constants
- Prefix unused parameters with `_`: `(_req: Request) => ...`

### Error Handling

- Use `try/catch` with `instanceof Error` checks
- Throw `new Error(message)` — never throw strings
- HTTP errors: return `c.json({ error: 'description' }, statusCode)` via Hono
- Re-throw `HTTPException` from Hono; catch everything else as 500
- Crypto/security failures: return immediately, never expose internals
- Use `timingSafeEqual` for signature comparisons

### Testing

- Framework: `node:test` (built-in `describe`, `it`, `mock`)
- Assertions: `node:assert` (`strictEqual`, `ok`, `notStrictEqual`, `throws`, `deepStrictEqual`)
- Test files: `test/*.test.ts`
- Pattern: mock `globalThis.fetch` with `mock.fn()`, restore in `finally` block
- Use Hono's `app.request()` for HTTP handler tests (no actual server needed)
- Always clean up `process.env` mutations in tests (`delete process.env.X`)

```typescript
import { describe, it, mock } from 'node:test'
import { strictEqual, ok } from 'node:assert'

describe('featureName', () => {
it('does the expected thing', () => {
strictEqual(actual, expected)
})
})
```

### Security Patterns

- Path validation: reject `://`, `//`, null bytes, CR/LF, backslashes
- Header filtering: block `Authorization`, `Cookie`, `Host`, proxy headers
- Request limits: max 50 requests per sandbox execution
- Response size limits: 10MB per response
- Memory limits: 64MB per V8 isolate
- Timeouts: 30s CPU, 60s wall-clock per sandbox execution

## Environment Variables

| Variable | Required | Description |
| ---------------------- | --------- | ------------------------------------------------------- |
| `PORT` | No | Server port (default: 3000) |
| `OAUTH_SECRET` | Prod only | Base64url-encoded 32-byte key for signing/encryption |
| `PUBLIC_URL` | No | Public-facing URL (default: https://mcp.sevalla.com) |
| `SEVALLA_FRONTEND_URL` | No | Sevalla frontend URL (default: https://app.sevalla.com) |
| `NODE_ENV` | No | Set to `production` to require OAUTH_SECRET |
| `SHUTDOWN_TIMEOUT_MS` | No | Graceful shutdown timeout (default: 30000) |
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"packageManager": "pnpm@10.30.3",
"version": "1.0.0",
"type": "module",
"volta": {
"node": "24.14.0",
"pnpm": "10.30.3"
},
"scripts": {
"dev": "node --watch src/index.ts",
"start": "node src/index.ts",
Expand Down
28 changes: 28 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const VERSION_PREFIX_PATTERN = /^\/v\d+(?=\/|$)/

export const SEVALLA_API_BASE = 'https://api.sevalla.com'
export const SEVALLA_API_PREFIX = '/v3'
export const SEVALLA_SPEC_URL = `${SEVALLA_API_BASE}${SEVALLA_API_PREFIX}/openapi.json`

export const normalizeApiPath = (path: string): string => {
const normalized = path.replace(VERSION_PREFIX_PATTERN, '')
return normalized || '/'
}

export const prependApiPrefix = (path: string): string => {
return `${SEVALLA_API_PREFIX}${normalizeApiPath(path)}`
}

export const createAuthenticatedFetch = (token: string) => {
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const rawUrl = typeof input === 'string' || input instanceof URL ? input.toString() : input.url
const url = new URL(rawUrl)
url.pathname = prependApiPrefix(url.pathname)

const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${token}`)
headers.set('Content-Type', 'application/json')

return fetch(url.toString(), { ...init, headers })
}
}
18 changes: 2 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@ import { serve } from '@hono/node-server'
import { StreamableHTTPTransport } from '@hono/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { createTools } from './sandbox/index.ts'
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { cors } from 'hono/cors'
import { createAuthenticatedFetch, SEVALLA_API_BASE, SEVALLA_SPEC_URL } from './api.ts'
import { createOAuthRouter } from './oauth.ts'
import { INDEX_HTML } from './html.ts'
import { createTools } from './sandbox/index.ts'

const IS_STDIO = process.argv.includes('--stdio')
if (IS_STDIO) {
console.log = console.error
}

const PORT = parseInt(process.env.PORT || '3000', 10)
const SEVALLA_API_BASE = 'https://api.sevalla.com'
const SEVALLA_SPEC_URL = 'https://api.sevalla.com/v3/openapi.json'
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '30000', 10)

let specPromise: Promise<Record<string, unknown>> | null = null
Expand All @@ -39,19 +38,6 @@ const loadSpec = (): Promise<Record<string, unknown>> => {
return specPromise
}

const createAuthenticatedFetch = (token: string) => {
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? new URL(input) : new URL(input.toString())
url.pathname = '/v3' + url.pathname

const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${token}`)
headers.set('Content-Type', 'application/json')

return fetch(url.toString(), { ...init, headers })
}
}

const createMcpServer = (spec: Record<string, unknown>, token: string): McpServer => {
const tools = createTools({
spec,
Expand Down
17 changes: 1 addition & 16 deletions src/sandbox/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,10 @@ export const resolveRefs = (
return out
}

const extractServerBasePath = (spec: any): string => {
const serverUrl = spec.servers?.[0]?.url
if (!serverUrl) {
return ''
}
try {
return new URL(serverUrl).pathname.replace(/\/+$/, '')
} catch {
const match = serverUrl.match(/^https?:\/\/[^/]+(\/.*?)?\/?$/)
return match?.[1]?.replace(/\/+$/, '') ?? ''
}
}

export const processSpec = (spec: any, maxRefDepth = DEFAULT_MAX_REF_DEPTH): { paths: Record<string, any> } => {
const basePath = extractServerBasePath(spec)
const paths: Record<string, any> = {}

for (const [path, methods] of Object.entries(spec.paths ?? {})) {
const fullPath = basePath + path
const pathItem: Record<string, any> = {}

for (const method of HTTP_METHODS) {
Expand All @@ -96,7 +81,7 @@ export const processSpec = (spec: any, maxRefDepth = DEFAULT_MAX_REF_DEPTH): { p
}

if (Object.keys(pathItem).length > 0) {
paths[fullPath] = pathItem
paths[path] = pathItem
}
}

Expand Down
11 changes: 7 additions & 4 deletions src/sandbox/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ declare const spec: {
const createSearchDefinition = (context: { tags: string[]; endpointCount: number }) => {
const parts: string[] = []
parts.push('Search the API specification to discover available endpoints. All $refs are pre-resolved inline.')
parts.push('Paths in `spec.paths` are ready to pass to the execute tool unchanged.')

if (context.tags.length > 0) {
const shown = context.tags.slice(0, 30).join(', ')
Expand Down Expand Up @@ -144,6 +145,8 @@ declare const ${namespace}: {
},
description: `Execute API calls by writing JavaScript code. First use the 'search' tool to find the right endpoints.

Use the exact path returned by the search tool. Do not pass a full URL or add an extra API version prefix.

Available in your code:
${types}
Your code must be an async arrow function that returns the result.
Expand All @@ -152,26 +155,26 @@ Examples:

// List resources
async () => {
const res = await ${namespace}.request({ method: "GET", path: "/v1/items" });
const res = await ${namespace}.request({ method: "GET", path: "/items" });
return res.body;
}

// Create a resource
async () => {
const res = await ${namespace}.request({
method: "POST",
path: "/v1/items",
path: "/items",
body: { name: "Widget" }
});
return { status: res.status, body: res.body };
}

// Chain multiple calls
async () => {
const list = await ${namespace}.request({ method: "GET", path: "/v1/items" });
const list = await ${namespace}.request({ method: "GET", path: "/items" });
const details = await Promise.all(
list.body.map(item =>
${namespace}.request({ method: "GET", path: \`/v1/items/\${item.id}\` })
${namespace}.request({ method: "GET", path: \`/items/\${item.id}\` })
)
);
return details.map(d => d.body);
Expand Down
Loading
Loading