From Static Sites to MCP: The Vyriy Server Family
Some server frameworks start from a big abstraction.
Vyriy starts from a smaller idea:
A handler should stay readable, portable, and boring.
The server-side family of Vyriy packages is built around a few small pieces:
import { server } from '@vyriy/server';
import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
import { withStatic } from '@vyriy/static';
Together, these packages can cover many real server shapes:
- native Node.js HTTP servers
- AWS Lambda handlers
- Lambda streaming responses
- API routes
- static builds
- SPA fallback
- Docker containers
- GraphQL endpoints
- MCP servers
The goal is not to hide the platform.
The goal is to keep the flow calm.
The core idea
Each package has a small responsibility.
@vyriy/server starts the runtime.
@vyriy/handler adapts the request/response contract and adds practical API behavior like health checks.
@vyriy/router keeps routing explicit.
@vyriy/static makes static builds and SPA output easy to serve.
This gives a project a simple server layer that can stay small, but still work across different deployment targets.
The same application shape can run locally in Node.js, inside Docker, behind a static UI, as a Lambda-compatible handler, or as a more specialized HTTP runtime.
Static UI with an API
A common project shape is a static UI with a small API.
With Vyriy, this can stay very direct:
import { server } from '@vyriy/server';
import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
import { withStatic } from '@vyriy/static';
import { path } from '@vyriy/path';
import * as handlers from '@p/api';
server(
api(async (event) => withStatic(createRouter()).get('/api/', handlers.status).static('/', path('ui')).route(event)),
);
This is the kind of flow I like.
The API route is visible.
The static UI path is visible.
The router composition is visible.
The server entry point is visible.
There is no hidden build-time magic here. The application reads almost like a small deployment story.
Static builds should be easy to run
Static output should not require a complex server setup.
For example, Storybook builds, SPA builds, or simple static site builds can be served with @vyriy/static.
When the static server is installed globally, a build can be started with a small command:
vs ./storybook-static
The same idea works for other static folders too.
The point is not to create another heavy server framework. The point is to make local previews and simple static hosting boring.
Docker stays boring too
The same kind of application can be wrapped into a small Docker image:
FROM node:krypton-alpine
WORKDIR /app
ENV PORT=3000
ENV LOG_LEVEL=info
ENV NODE_ENV=production
COPY package.json .
RUN npm install
COPY --chown=node:node . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthcheck" || exit 1
CMD ["node", "index.js"]
The important part is that the application contract remains simple.
The handler can provide a health check.
The server can run in Node.js.
The same architecture can later move closer to Lambda or another HTTP runtime without rewriting the whole project shape.
Why native HTTP support matters
Not every server use case fits into a simplified event object.
Sometimes native Node.js HTTP request and response objects are exactly what you need.
This is especially important for protocols and transports that work directly with streams.
MCP is a good example.
A Streamable HTTP MCP server needs access to native Node.js request and response objects. Vyriy keeps that possible.
Running an MCP server
The same server family can be used to run an MCP server over Streamable HTTP.
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { create } from '@vyriy/handler';
import { createHttpRouter } from '@vyriy/router';
import { httpServer } from '@vyriy/server';
import { getBody } from '@vyriy/server/body';
import { createMcpServer } from './mcp.js';
const HTTP_HEADERS = {
'access-control-allow-headers': 'content-type, mcp-protocol-version',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-origin': '*',
'x-content-type-options': 'nosniff',
};
const json = (res: ServerResponse, statusCode: number, body: unknown) => {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
};
const jsonRpcError = (res: ServerResponse, statusCode: number, code: number, message: string) =>
json(res, statusCode, {
jsonrpc: '2.0',
error: { code, message },
id: null,
});
const readJsonBody = async (req: IncomingMessage) => {
const body = (await getBody(req))?.trim();
return body ? (JSON.parse(body) as unknown) : undefined;
};
const handleMcpRequest = async (req: IncomingMessage, res: ServerResponse) => {
if ((req.method ?? 'GET') !== 'POST') {
jsonRpcError(res, 405, -32000, 'Method not allowed.');
return;
}
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
try {
const body = await readJsonBody(req);
await server.connect(transport);
await transport.handleRequest(req, res, body);
} catch (error) {
console.error(error);
if (!res.headersSent) {
jsonRpcError(res, 500, -32603, 'Internal server error.');
}
} finally {
await transport.close();
await server.close();
}
};
export const createHttpHandler = () => {
const router = createHttpRouter();
router.all('/mcp', handleMcpRequest);
router.fallback((_req, res) => {
json(res, 404, {
message: 'Not found.',
});
});
return create.httpApi({
headers: HTTP_HEADERS,
healthcheck: {
body: {
ok: true,
name: '@vyriy/mcp',
transport: 'streamable-http',
},
},
})(router.handle());
};
export const startHttpServer = async () => {
const server = httpServer(createHttpHandler());
await new Promise<void>((resolve, reject) => {
if (server.listening) {
resolve();
return;
}
server.once('error', reject);
server.once('listening', resolve);
});
const address = server.address() as AddressInfo;
console.warn(`Vyriy MCP HTTP server listening on http://localhost:${address.port}/mcp`);
return server;
};
The MCP server itself can stay focused on tools:
import type { z } from 'zod';
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { getInstallCommandTool } from '@p/get-install-command';
import { getPackageDocTool } from '@p/get-package-doc';
import { listPackagesTool } from '@p/list-packages';
import { pingTool } from '@p/ping';
import { searchDocsTool } from '@p/search-docs';
import { serverInfoTool } from '@p/server-info';
import type { ToolDefinition } from './types.js';
const registerTool = <Input extends z.ZodRawShape>(server: McpServer, tool: ToolDefinition<Input>) => {
const callback = ((args: unknown) => tool.handler(args as z.infer<z.ZodObject<Input>>)) as ToolCallback<Input>;
server.registerTool(
tool.name,
{
description: tool.description,
inputSchema: tool.inputSchema,
},
callback,
);
};
export const createMcpServer = () => {
const server = new McpServer({
name: '@vyriy/mcp',
version: '0.0.0',
});
registerTool(server, pingTool);
registerTool(server, serverInfoTool);
registerTool(server, listPackagesTool);
registerTool(server, searchDocsTool);
registerTool(server, getPackageDocTool);
registerTool(server, getInstallCommandTool);
return server;
};
This is the part I care about most: the MCP logic is not mixed with server startup logic.
The HTTP transport is explicit.
The router is explicit.
The health check is explicit.
The tools are explicit.
That is the Vyriy style.
GraphQL fits the same shape
GraphQL also fits into the same model.
A router can expose GraphiQL on GET and execute GraphQL operations on POST:
import { createRouter } from '@vyriy/router';
import { graphql } from 'graphql';
import { graphiql } from './graphiql.js';
import { schema } from './schema.js';
export const router = createRouter();
router.get('/', () => ({
headers: {
'content-type': 'text/html; charset=UTF-8',
},
body: graphiql(),
}));
router.post('/', async (params) => {
let payload: {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
if (!params.body) {
return {
statusCode: 400,
body: JSON.stringify({
errors: [{ message: 'Invalid JSON body' }],
}),
};
}
try {
payload = JSON.parse(params.body) as typeof payload;
} catch {
return {
statusCode: 400,
body: JSON.stringify({
errors: [{ message: 'Invalid JSON body' }],
}),
};
}
if (!payload.query) {
return {
statusCode: 400,
body: JSON.stringify({
errors: [{ message: 'Missing GraphQL query' }],
}),
};
}
return {
body: JSON.stringify(
await graphql({
schema,
source: payload.query,
variableValues: payload.variables,
operationName: payload.operationName,
contextValue: {
event: params.event,
headers: params.headers ?? {},
},
}),
),
};
});
And the server entry stays small:
import { server } from '@vyriy/server';
import { api } from '@vyriy/handler';
import { router } from '@p/graphql';
server(api(router.handle()));
The interesting part is not that Vyriy can run GraphQL.
Many tools can.
The interesting part is that GraphQL does not need a different project philosophy.
It is still:
- create a router
- define explicit routes
- adapt with a handler
- start the server
One family, many runtimes
This is the main reason I like this package family.
A small API can run locally.
The same shape can be wrapped in Docker.
The same handler model can work with AWS Lambda.
Streaming responses can be supported when the runtime supports them.
Static builds can be served without introducing another server layer.
MCP can run because native HTTP primitives are still available.
GraphQL can be added without changing the project philosophy.
The package family stays small, but the composition is flexible.
Calm server architecture
For me, this is the main point of Vyriy.
I want infrastructure code to feel understandable.
Not magical.
Not over-abstracted.
Not tied to one deployment target too early.
A good server flow should be readable almost like a story:
- create a router
- define the routes
- wrap it with a handler
- start the server
That is the kind of server architecture I want from Vyriy:
small packages, explicit composition, many runtimes, calm deployment.