
Contract-driven APIs for TypeScript.
Contract-driven APIs for TypeScript, powered by ArkType.
Define your API contract with runtime-validated types, serve it over multiple transports (HTTP and MCP currently), and consume it with fully typed clients.
A contract is a set of operations with validated inputs and outputs. Use spec() to declare your contract along with the transports and middleware it supports:
import { spec, http, mcp, type OperationMiddlewareConfig } from "@weapon/spec"
import { type } from "arktype"
type AuthorizeConfig = { user?: boolean; role?: string }
function authorize(): OperationMiddlewareConfig<void, AuthorizeConfig> {
return { kind: "middleware" }
}
export const Spec = spec(
{
http: http({ authenticate: http.authenticate.cookie<User>("session") }),
mcp: mcp({ name: "my-api", version: "1.0.0" }),
authorize: authorize(),
},
{
tasks: {
list: {
http: "GET /tasks",
mcp: { readOnly: true },
authorize: { user: true },
description: "List all tasks",
input: type({}),
output: type({ id: "string", title: "string", done: "boolean" }).array(),
},
create: {
http: "POST /tasks",
mcp: true,
authorize: { user: true },
input: type({ title: "string" }),
output: type({ id: "string", title: "string", done: "boolean" }),
},
get: {
http: "GET /tasks/{id}",
mcp: { readOnly: true },
authorize: { user: true },
input: type({ id: "string" }),
output: type({ id: "string", title: "string", done: "boolean" }),
},
},
},
)
Every input and output is an ArkType type. ArkType gives you concise type syntax with full runtime validation -- your contract types are enforced at the boundary, not just at compile time.
A service is the protocol-agnostic implementation of a contract. Each operation maps to a handler function that receives validated input and a dependency injector.
const TaskService = Spec.contract.tasks.service({
async list(_, { db }: { db: Database }) {
const tasks = await db.query("SELECT * FROM tasks")
return tasks
},
async create({ title }, { db }) {
const task = { id: crypto.randomUUID(), title, done: false }
await db.insert("tasks", task)
return task
},
async get({ id }, { db }) {
return await db.queryOne("SELECT * FROM tasks WHERE id = ?", [id])
},
})
Services are bound to their contract via contract.service(impl), producing a BoundService that can be mounted on any transport.
The gateway wires your contract to an HTTP server. It matches incoming requests to operations by method + path, resolves authentication, parses input from the body/query/path params, and serializes the response.
import { gateway } from "@weapon/gateway"
const api = gateway(
Spec,
Spec.transports.http,
{
authenticate: async (sessionId) => {
return await lookupSession(sessionId)
},
authorize: {
onRequest(config, container) {
if (config.user) {
const identity = container.resolve("identity")
if (!identity) throw new Error("Unauthorized")
}
},
},
},
[TaskService],
)
// api.fetch is a standard Request -> Response handler
Bun.serve({ fetch: api.fetch })
The connector wires your contract to an MCP server. Operations with mcp config become tools. Supports both Streamable HTTP (JSON-RPC over fetch) and stdio transports.
import { connector } from "@weapon/connector"
const mcp = connector(
Spec,
Spec.transports.mcp,
{
authorize: {
onRequest(config, container) {
// MCP authorization logic
},
},
},
[TaskService],
)
// Streamable HTTP
Bun.serve({ fetch: mcp.fetch })
// Or stdio
await mcp.serve()
The remote client mirrors your contract as typed async functions. It reads the HTTP route config from each operation to build requests automatically.
import { remote } from "@weapon/remote"
const api = remote(Spec, Spec.transports.http, {
base: "https://api.example.com",
authenticate: () => getSessionToken(),
})
const tasks = await api.tasks.list({})
const task = await api.tasks.create({ title: "Buy milk" })
const found = await api.tasks.get({ id: task.id })
There is also an experimental query package integrating with TanStack Query.
Contracts can nest arbitrarily via scopes:
const Spec = spec({ http: http() }, {
users: {
list: { http: "GET /users", input: type({}), output: type({}).array() },
get: { http: "GET /users/{id}", input: type({ id: "string" }), output: type({}) },
settings: {
get: { http: "GET /users/{id}/settings", input: type({ id: "string" }), output: type({}) },
update: { http: "PUT /users/{id}/settings", input: type({ id: "string" }), output: type({}) },
},
},
})
// Services mirror the structure
const UserService = Spec.contract.users.service({
list: async (input, ctx) => { ... },
get: async (input, ctx) => { ... },
settings: {
get: async (input, ctx) => { ... },
update: async (input, ctx) => { ... },
},
})
Scopes are also reflected in clients:
await api.users.list({})
await api.users.settings.get({ id: "123" })
Weapon separates auth declaration (in the contract) from auth resolution (in the gateway/connector).
// Cookie-based session
http({ authenticate: http.authenticate.cookie<User>("session") })
// Bearer token
http({ authenticate: http.authenticate.bearer<User>() })
// API key via header
http({ authenticate: http.authenticate.header<User>("X-API-Key") })
// HTTP Basic
http({ authenticate: http.authenticate.basic<User>() })
// MCP OAuth 2.1
mcp({ authenticate: mcp.authenticate.oauth<User>() })
The generic parameter (<User>) is the identity type your resolver returns. It flows through to the gateway/connector config, ensuring the resolver signature matches.
On the server, you provide a resolver that matches the declared scheme:
// Cookie -> resolver receives the cookie value
gateway(Spec, Spec.transports.http, {
authenticate: (sessionId: string) => lookupUser(sessionId),
// ...
})
// Bearer -> resolver receives the token
gateway(Spec, Spec.transports.http, {
authenticate: (token: string) => verifyJwt(token),
// ...
})
// Basic -> resolver receives username + password
gateway(Spec, Spec.transports.http, {
authenticate: (username: string, password: string) => verifyCredentials(username, password),
// ...
})
The resolved identity is bound into the DI container as identity and is available to middleware and service handlers.
Middleware is declared in the contract and configured per-operation:
// Declaration (spec-level)
type RateLimitConfig = { requests: number; window: number }
function rateLimit(): OperationMiddlewareConfig<void, RateLimitConfig> {
return { kind: "middleware" }
}
const Spec = spec({
http: http({ ... }),
rateLimit: rateLimit(),
}, {
heavyOperation: {
http: "POST /heavy",
rateLimit: { requests: 10, window: 60 }, // per-operation config
input: type({}),
output: type({}),
},
})
On the server, you provide the middleware implementation:
gateway(Spec, Spec.transports.http, {
authenticate: ...,
rateLimit: {
onRequest(config, container) {
// config = { requests: 10, window: 60 }
// check rate limit, throw to reject
},
onResponse(config, container) {
// runs after handler, in reverse declaration order
},
},
}, services)
onRequest runs before the handler (use for authorization, rate limiting, validation). onResponse runs after (use for audit logging, response transforms). Both are optional.
MIT