diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6866cfb --- /dev/null +++ b/AGENTS.md @@ -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` syntax (not `T[]`) per `@typescript-eslint/array-type` +- Use `Record` 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` 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) | diff --git a/package.json b/package.json index 08339d8..829eab1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..4df2fb1 --- /dev/null +++ b/src/api.ts @@ -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 => { + 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 }) + } +} diff --git a/src/index.ts b/src/index.ts index bb00b79..d956f48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,13 @@ 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) { @@ -15,8 +16,6 @@ if (IS_STDIO) { } 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> | null = null @@ -39,19 +38,6 @@ const loadSpec = (): Promise> => { return specPromise } -const createAuthenticatedFetch = (token: string) => { - return async (input: string | URL | Request, init?: RequestInit): Promise => { - 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, token: string): McpServer => { const tools = createTools({ spec, diff --git a/src/sandbox/spec.ts b/src/sandbox/spec.ts index 5150c71..96ad2b1 100644 --- a/src/sandbox/spec.ts +++ b/src/sandbox/spec.ts @@ -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 } => { - const basePath = extractServerBasePath(spec) const paths: Record = {} for (const [path, methods] of Object.entries(spec.paths ?? {})) { - const fullPath = basePath + path const pathItem: Record = {} for (const method of HTTP_METHODS) { @@ -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 } } diff --git a/src/sandbox/tools.ts b/src/sandbox/tools.ts index 8e1d626..3507d13 100644 --- a/src/sandbox/tools.ts +++ b/src/sandbox/tools.ts @@ -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(', ') @@ -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. @@ -152,7 +155,7 @@ 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; } @@ -160,7 +163,7 @@ async () => { async () => { const res = await ${namespace}.request({ method: "POST", - path: "/v1/items", + path: "/items", body: { name: "Widget" } }); return { status: res.status, body: res.body }; @@ -168,10 +171,10 @@ async () => { // 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); diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index d7b4aaf..ebfd141 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -48,7 +48,7 @@ describe('resolveRefs', () => { }) describe('processSpec', () => { - it('extracts operations with server base path prepended', () => { + it('extracts operations without prepending the server base path', () => { const spec = { servers: [{ url: 'https://api.example.com/v3' }], paths: { @@ -60,10 +60,10 @@ describe('processSpec', () => { components: {}, } const result = processSpec(spec) - strictEqual(result.paths['/v3/items'] !== undefined, true) - strictEqual(result.paths['/v3/items'].get.summary, 'List items') - strictEqual(result.paths['/v3/items'].post.summary, 'Create item') - strictEqual(result.paths['/items'], undefined) + strictEqual(result.paths['/items'] !== undefined, true) + strictEqual(result.paths['/items'].get.summary, 'List items') + strictEqual(result.paths['/items'].post.summary, 'Create item') + strictEqual(result.paths['/v3/items'], undefined) }) it('drops fields not in the extraction list', () => { @@ -275,6 +275,8 @@ describe('createTools', () => { }) const execDesc = tools.definitions[1].description strictEqual(execDesc.includes('myapi'), true) + strictEqual(execDesc.includes('path: "/items"'), true) + strictEqual(execDesc.includes('/v1/items'), false) }) it('search tool handler executes code against processed spec', async () => { @@ -290,6 +292,19 @@ describe('createTools', () => { strictEqual(result.content[0].text, '2') }) + it('search tool exposes execute-ready paths', async () => { + const tools = createTools({ + spec: mockSpec, + request: async () => new Response('{}'), + baseUrl: 'https://api.example.com', + namespace: 'myapi', + }) + const result = await tools.definitions[0].handler({ + code: 'async () => Object.keys(spec.paths)', + }) + deepStrictEqual(JSON.parse(result.content[0].text), ['/items', '/users']) + }) + it('inputSchema is a Zod schema with code property', () => { const tools = createTools({ spec: mockSpec, diff --git a/test/server.test.ts b/test/server.test.ts index f24a7e6..93c59e1 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,22 +1,55 @@ -import { describe, it } from 'node:test' +import { describe, it, mock } from 'node:test' import { strictEqual } from 'node:assert' +import { createAuthenticatedFetch, normalizeApiPath, prependApiPrefix } from '../src/api.ts' -describe('createAuthenticatedFetch', () => { - it('prepends /v3 to the path', () => { - const url = new URL('https://api.sevalla.com/applications') - url.pathname = '/v3' + url.pathname - strictEqual(url.toString(), 'https://api.sevalla.com/v3/applications') +describe('normalizeApiPath', () => { + it('keeps versionless paths unchanged', () => { + strictEqual(normalizeApiPath('/applications'), '/applications') + }) + + it('strips a leading version prefix', () => { + strictEqual(normalizeApiPath('/v1/applications'), '/applications') + strictEqual(normalizeApiPath('/v3/applications'), '/applications') + }) +}) + +describe('prependApiPrefix', () => { + it('adds /v3 to versionless paths', () => { + strictEqual(prependApiPrefix('/applications'), '/v3/applications') }) - it('preserves query parameters', () => { - const url = new URL('https://api.sevalla.com/applications?page=1&limit=25') - url.pathname = '/v3' + url.pathname - strictEqual(url.toString(), 'https://api.sevalla.com/v3/applications?page=1&limit=25') + it('normalizes versioned paths before prefixing', () => { + strictEqual(prependApiPrefix('/v1/applications'), '/v3/applications') + strictEqual(prependApiPrefix('/v3/applications'), '/v3/applications') }) +}) + +describe('createAuthenticatedFetch', () => { + it('rewrites versioned URLs without double-prefixing and preserves query parameters', async () => { + const fetchMock = mock.fn( + async (_input: string | URL | Request, _init?: RequestInit): Promise => + new Response('{}', { headers: { 'content-type': 'application/json' } }), + ) + const originalFetch = globalThis.fetch + globalThis.fetch = fetchMock as typeof fetch + + try { + const authenticatedFetch = createAuthenticatedFetch('test-token') + await authenticatedFetch('https://api.sevalla.com/v1/applications?page=1&limit=25') + + strictEqual(fetchMock.mock.calls.length, 1) + const call = fetchMock.mock.calls[0] + if (!call) { + throw new Error('Expected fetch to be called') + } + const [url, init] = call.arguments as [string, RequestInit | undefined] + strictEqual(url, 'https://api.sevalla.com/v3/applications?page=1&limit=25') - it('handles nested paths', () => { - const url = new URL('https://api.sevalla.com/applications/123/deployments') - url.pathname = '/v3' + url.pathname - strictEqual(url.toString(), 'https://api.sevalla.com/v3/applications/123/deployments') + const headers = new Headers(init?.headers) + strictEqual(headers.get('authorization'), 'Bearer test-token') + strictEqual(headers.get('content-type'), 'application/json') + } finally { + globalThis.fetch = originalFetch + } }) })