@ruco-ai/mcpster 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # mcpster
2
+
3
+ > A TypeScript SDK for building MCP servers — removes the boilerplate so you focus on what you expose, not how.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ mcpster is an agnostic TypeScript SDK for building Model Context Protocol (MCP) servers. It wraps `@modelcontextprotocol/sdk` behind a fluent, chainable API with Zod-first schema validation, URI template parsing, and consistent error handling. Servers built with mcpster start as local stdio processes and have a clear migration path to remote hosting and public npm distribution — without rewriting any business logic.
10
+
11
+ ## Features
12
+
13
+ - `createServer()` + chainable `defineTool` / `defineResource` / `definePrompt` API
14
+ - Zod schema validation enforced at tool registration time
15
+ - URI template parameter extraction for resources (`templates://{name}`)
16
+ - Automatic error wrapping — handlers throw, mcpster returns MCP-compliant error responses
17
+ - Scope-aware server naming (project, user, or public scope)
18
+ - stdio and Streamable HTTP transports — switch with a single config field
19
+ - OAuth 2.1 (PKCE) support for protected remote servers — enable with `auth: true`
20
+ - Designed for progressive deployment: local stdio → remote Streamable HTTP → hosted → npm package
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install mcpster
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### stdio (default)
31
+
32
+ ```typescript
33
+ import { createServer } from 'mcpster'
34
+ import { z } from 'zod'
35
+
36
+ createServer({ name: 'my-server', version: '1.0.0' })
37
+ .defineTool({
38
+ name: 'get_template',
39
+ description: 'Retrieve a template by name',
40
+ schema: z.object({ name: z.string() }),
41
+ handler: async ({ name }) => { /* ... */ },
42
+ })
43
+ .defineResource({
44
+ uri: 'templates://{name}',
45
+ description: 'Template content by name',
46
+ resolver: async ({ name }) => { /* ... */ },
47
+ })
48
+ .definePrompt({
49
+ name: 'summarize',
50
+ handler: async (args) => { /* ... */ },
51
+ })
52
+ .start() // stdio transport
53
+ ```
54
+
55
+ Register the server per-project:
56
+
57
+ ```bash
58
+ claude mcp add my-server -- npx mcpster start
59
+ ```
60
+
61
+ ### Streamable HTTP transport
62
+
63
+ ```typescript
64
+ import { createServer } from 'mcpster'
65
+
66
+ createServer({
67
+ name: 'my-server',
68
+ version: '1.0.0',
69
+ transport: 'http',
70
+ http: { port: 3000, path: '/mcp' },
71
+ })
72
+ .defineTool({ /* ... */ })
73
+ .start() // listens on http://localhost:3000/mcp
74
+ ```
75
+
76
+ Register with Claude Code:
77
+
78
+ ```bash
79
+ claude mcp add --transport http my-server http://localhost:3000/mcp
80
+ ```
81
+
82
+ ### OAuth 2.1 (protected remote server)
83
+
84
+ Enable `auth: true` to require clients to complete an OAuth 2.1 PKCE flow before accessing the MCP endpoint. Set `baseUrl` to your public server URL so OAuth discovery metadata points to the right place.
85
+
86
+ ```typescript
87
+ createServer({
88
+ name: 'my-server',
89
+ version: '1.0.0',
90
+ transport: 'http',
91
+ http: {
92
+ port: 3000,
93
+ path: '/mcp',
94
+ auth: true,
95
+ baseUrl: 'https://my-server.example.com',
96
+ },
97
+ }).start()
98
+ ```
99
+
100
+ mcpster handles the full OAuth flow: dynamic client registration (`/register`), authorization (`/authorize`), token issuance (`/token`), and bearer token verification on the MCP endpoint. All dynamically registered clients are treated as public PKCE clients — no pre-shared secrets required.
101
+
102
+ For persistence across restarts, point `clientsFile` at a file on a mounted volume:
103
+
104
+ ```typescript
105
+ http: {
106
+ auth: true,
107
+ baseUrl: 'https://my-server.example.com',
108
+ clientsFile: '/data/oauth-clients.json',
109
+ }
110
+ ```
111
+
112
+ ## Configuration
113
+
114
+ | Option | Default | Description |
115
+ |--------|---------|-------------|
116
+ | `name` | required | Server name, used for MCP registration |
117
+ | `version` | required | Semver string |
118
+ | `scope` | `process.cwd()` | Project root; determines scope for naming and resource resolution |
119
+ | `transport` | `'stdio'` | Transport to use: `'stdio'` or `'http'` |
120
+ | `http.port` | `3000` | Port to listen on (HTTP transport only) |
121
+ | `http.path` | `'/mcp'` | Request path to handle (HTTP transport only) |
122
+ | `http.auth` | `false` | Enable OAuth 2.1 PKCE protection on the MCP endpoint |
123
+ | `http.baseUrl` | `http://localhost:<port>` | Public base URL — used for OAuth metadata and token endpoints |
124
+ | `http.clientsFile` | *(in-memory)* | Path to persist registered OAuth clients across restarts |
125
+
126
+ ## Project Structure
127
+
128
+ ```
129
+ mcpster/
130
+ ├── src/
131
+ │ ├── index.ts # Public API — re-exports createServer and types
132
+ │ ├── server.ts # McpsterServer class — core orchestrator
133
+ │ ├── tool.ts # defineTool — schema validation + handler wiring
134
+ │ ├── resource.ts # defineResource — URI template matching + resolver
135
+ │ ├── prompt.ts # definePrompt — prompt template registration
136
+ │ ├── transport/
137
+ │ │ ├── stdio.ts # stdio transport adapter
138
+ │ │ └── http.ts # Streamable HTTP transport adapter
139
+ │ ├── deploy/
140
+ │ │ ├── types.ts # Shared DeployConfig, DeployResult, DeployAdapter types
141
+ │ │ ├── railway.ts # Railway adapter (generateManifest, deploy)
142
+ │ │ ├── fly.ts # Fly.io adapter (generateManifest, generateDockerfile, deploy)
143
+ │ │ ├── cloudflare.ts # Cloudflare Workers adapter (generateManifest, generateWorkerShim, deploy)
144
+ │ │ └── cli.ts # mcpster-deploy CLI entry point
145
+ │ └── types.ts # Shared TypeScript types and interfaces
146
+ ├── tests/
147
+ │ ├── server.test.ts
148
+ │ ├── tool.test.ts
149
+ │ ├── resource.test.ts
150
+ │ ├── prompt.test.ts
151
+ │ ├── transport.test.ts
152
+ │ └── deploy.test.ts
153
+ ├── examples/
154
+ │ └── minimal/ # hello-mcp — tool + resource + prompt, runnable reference
155
+ ├── package.json
156
+ └── tsconfig.json
157
+ ```
158
+
159
+ ## Deploy (v3)
160
+
161
+ mcpster ships a deploy kit that pushes your Streamable HTTP server to Railway, Fly.io, or Cloudflare Workers with a single command. The Streamable HTTP transport must already be configured on your server before deploying — the deploy adapters wrap the existing `transport: 'http'` path.
162
+
163
+ ### Prerequisites
164
+
165
+ | Target | Prerequisite |
166
+ |--------|-------------|
167
+ | Railway | `railway` CLI installed and authenticated (`railway login`) |
168
+ | Fly.io | `flyctl` installed and authenticated (`fly auth login`) |
169
+ | Cloudflare Workers | `wrangler` installed and authenticated (`wrangler login`) |
170
+
171
+ ### Usage
172
+
173
+ ```bash
174
+ # Dry-run: print the generated manifest without deploying
175
+ npx mcpster-deploy --target railway --dry-run
176
+ npx mcpster-deploy --target fly --dry-run
177
+ npx mcpster-deploy --target cloudflare --dry-run
178
+
179
+ # Deploy
180
+ npx mcpster-deploy --target railway
181
+ npx mcpster-deploy --target fly --region lhr
182
+ npx mcpster-deploy --target cloudflare
183
+ ```
184
+
185
+ All options:
186
+
187
+ ```
188
+ -t, --target <target> Deploy target (railway | fly | cloudflare)
189
+ -n, --name <name> Server name (default: read from package.json)
190
+ -v, --version <version> Server version (default: read from package.json)
191
+ -p, --port <port> HTTP port (default: 3000)
192
+ -r, --region <region> Preferred region — Fly.io only (default: iad)
193
+ -d, --dry-run Print the generated manifest without deploying
194
+ -h, --help Show help
195
+ ```
196
+
197
+ ### Local → Hosted migration path
198
+
199
+ **Stage 1 — Local stdio** (no infrastructure)
200
+
201
+ ```typescript
202
+ createServer({ name: 'my-server', version: '1.0.0' }).start()
203
+ ```
204
+
205
+ **Stage 2 — Local Streamable HTTP** (same server, different transport)
206
+
207
+ ```typescript
208
+ createServer({
209
+ name: 'my-server',
210
+ version: '1.0.0',
211
+ transport: 'http',
212
+ http: { port: 3000, path: '/mcp' },
213
+ }).start()
214
+ ```
215
+
216
+ **Stage 3 — Hosted** (one command, no code changes)
217
+
218
+ ```bash
219
+ npx mcpster-deploy --target railway
220
+ # → Deployed to Railway: https://my-server.up.railway.app
221
+ ```
222
+
223
+ Then connect Claude Code to the deployed server:
224
+
225
+ ```bash
226
+ claude mcp add --transport http my-server https://my-server.up.railway.app/mcp
227
+ ```
228
+
229
+ The server code is identical across all three stages.
230
+
231
+ ## License
232
+
233
+ MIT
234
+
235
+ ---
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcpster deploy CLI
4
+ *
5
+ * Usage:
6
+ * mcpster-deploy --target <railway|fly|cloudflare> [--name <name>] [--version <v>] [--port <port>] [--dry-run]
7
+ */
8
+ export {};
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcpster deploy CLI
4
+ *
5
+ * Usage:
6
+ * mcpster-deploy --target <railway|fly|cloudflare> [--name <name>] [--version <v>] [--port <port>] [--dry-run]
7
+ */
8
+ import { parseArgs } from 'node:util';
9
+ const { values } = parseArgs({
10
+ options: {
11
+ target: { type: 'string', short: 't' },
12
+ name: { type: 'string', short: 'n' },
13
+ version: { type: 'string', short: 'v' },
14
+ port: { type: 'string', short: 'p' },
15
+ region: { type: 'string', short: 'r' },
16
+ 'dry-run': { type: 'boolean', short: 'd' },
17
+ help: { type: 'boolean', short: 'h' },
18
+ },
19
+ allowPositionals: false,
20
+ });
21
+ if (values.help || !values.target) {
22
+ console.log(`
23
+ mcpster deploy — push your MCP server to a hosted platform
24
+
25
+ Usage:
26
+ mcpster-deploy --target <target> [options]
27
+
28
+ Targets:
29
+ railway Deploy to Railway (requires: railway CLI)
30
+ fly Deploy to Fly.io (requires: flyctl)
31
+ cloudflare Deploy to Cloudflare Workers (requires: wrangler)
32
+
33
+ Options:
34
+ -t, --target <target> Deploy target (railway | fly | cloudflare)
35
+ -n, --name <name> Server name (default: read from package.json)
36
+ -v, --version <version> Server version (default: read from package.json)
37
+ -p, --port <port> HTTP port (default: 3000)
38
+ -r, --region <region> Preferred region (fly only, default: iad)
39
+ -d, --dry-run Print the generated manifest without deploying
40
+ -h, --help Show this help message
41
+ `);
42
+ process.exit(values.help ? 0 : 1);
43
+ }
44
+ const target = values.target;
45
+ if (!['railway', 'fly', 'cloudflare'].includes(target)) {
46
+ console.error(`Error: unknown target "${target}". Must be one of: railway, fly, cloudflare`);
47
+ process.exit(1);
48
+ }
49
+ // Resolve name and version from package.json if not provided
50
+ async function resolvePackageMeta() {
51
+ try {
52
+ const { readFileSync } = await import('node:fs');
53
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
54
+ return {
55
+ name: pkg.name ?? 'mcp-server',
56
+ version: pkg.version ?? '0.1.0',
57
+ };
58
+ }
59
+ catch {
60
+ return { name: 'mcp-server', version: '0.1.0' };
61
+ }
62
+ }
63
+ const meta = await resolvePackageMeta();
64
+ const config = {
65
+ name: values.name ?? meta.name,
66
+ version: values.version ?? meta.version,
67
+ port: values.port ? Number(values.port) : 3000,
68
+ region: values.region,
69
+ };
70
+ const dryRun = values['dry-run'] ?? false;
71
+ if (target === 'railway') {
72
+ const { generateManifest, deploy } = await import('./railway.js');
73
+ if (dryRun) {
74
+ console.log(JSON.stringify(generateManifest(config), null, 2));
75
+ }
76
+ else {
77
+ const result = await deploy(config);
78
+ console.log(`Deployed to Railway: ${result.url}`);
79
+ }
80
+ }
81
+ else if (target === 'fly') {
82
+ const { generateManifest, generateDockerfile, deploy } = await import('./fly.js');
83
+ if (dryRun) {
84
+ console.log('# fly.toml');
85
+ console.log(JSON.stringify(generateManifest(config), null, 2));
86
+ console.log('\n# Dockerfile');
87
+ console.log(generateDockerfile());
88
+ }
89
+ else {
90
+ const result = await deploy(config);
91
+ console.log(`Deployed to Fly.io: ${result.url}`);
92
+ }
93
+ }
94
+ else if (target === 'cloudflare') {
95
+ const { generateManifest, manifestToToml, generateWorkerShim, deploy } = await import('./cloudflare.js');
96
+ if (dryRun) {
97
+ console.log('# wrangler.toml');
98
+ console.log(manifestToToml(generateManifest(config)));
99
+ console.log('\n# worker.js');
100
+ console.log(generateWorkerShim(config));
101
+ }
102
+ else {
103
+ const result = await deploy(config);
104
+ console.log(`Deployed to Cloudflare Workers: ${result.url}`);
105
+ }
106
+ }
@@ -0,0 +1,11 @@
1
+ import type { DeployConfig, DeployResult } from './types.js';
2
+ export interface CloudflareManifest {
3
+ name: string;
4
+ main: string;
5
+ compatibility_date: string;
6
+ vars: Record<string, string>;
7
+ }
8
+ export declare function generateManifest(config: DeployConfig): CloudflareManifest;
9
+ export declare function generateWorkerShim(config: DeployConfig): string;
10
+ export declare function manifestToToml(manifest: CloudflareManifest): string;
11
+ export declare function deploy(config: DeployConfig): Promise<DeployResult>;
@@ -0,0 +1,57 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function generateManifest(config) {
3
+ return {
4
+ name: config.name,
5
+ main: 'worker.js',
6
+ compatibility_date: '2024-01-01',
7
+ vars: {
8
+ MCP_TRANSPORT: 'http',
9
+ SERVER_NAME: config.name,
10
+ SERVER_VERSION: config.version,
11
+ },
12
+ };
13
+ }
14
+ export function generateWorkerShim(config) {
15
+ const port = config.port ?? 3000;
16
+ return [
17
+ `// Worker entry shim for ${config.name} v${config.version}`,
18
+ `// Forwards requests to the MCP HTTP handler`,
19
+ `import { createServer } from './dist/index.js'`,
20
+ ``,
21
+ `const server = createServer({`,
22
+ ` name: '${config.name}',`,
23
+ ` version: '${config.version}',`,
24
+ ` transport: 'http',`,
25
+ ` http: { port: ${port}, path: '/mcp' },`,
26
+ `})`,
27
+ ``,
28
+ `export default {`,
29
+ ` async fetch(request, env, ctx) {`,
30
+ ` return server.handleRequest(request, env, ctx)`,
31
+ ` },`,
32
+ `}`,
33
+ ].join('\n');
34
+ }
35
+ export function manifestToToml(manifest) {
36
+ const lines = [
37
+ `name = "${manifest.name}"`,
38
+ `main = "${manifest.main}"`,
39
+ `compatibility_date = "${manifest.compatibility_date}"`,
40
+ ``,
41
+ `[vars]`,
42
+ ];
43
+ for (const [key, value] of Object.entries(manifest.vars)) {
44
+ lines.push(`${key} = "${value}"`);
45
+ }
46
+ return lines.join('\n');
47
+ }
48
+ export async function deploy(config) {
49
+ const manifest = generateManifest(config);
50
+ const { writeFileSync } = await import('node:fs');
51
+ writeFileSync('wrangler.toml', manifestToToml(manifest), 'utf8');
52
+ writeFileSync('worker.js', generateWorkerShim(config), 'utf8');
53
+ const output = execSync('wrangler deploy --json', { encoding: 'utf8' });
54
+ const result = JSON.parse(output);
55
+ const url = result.url ?? result.deployment_url ?? `https://${config.name}.workers.dev`;
56
+ return { url, target: 'cloudflare', manifest };
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { DeployConfig, DeployResult } from './types.js';
2
+ export interface FlyManifest {
3
+ app: string;
4
+ primary_region: string;
5
+ [services: string]: unknown;
6
+ }
7
+ export interface FlyDockerfile {
8
+ content: string;
9
+ }
10
+ export declare function generateManifest(config: DeployConfig): FlyManifest;
11
+ export declare function generateDockerfile(): string;
12
+ export declare function deploy(config: DeployConfig): Promise<DeployResult>;
@@ -0,0 +1,86 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readdirSync } from 'node:fs';
3
+ export function generateManifest(config) {
4
+ const port = config.port ?? 3000;
5
+ return {
6
+ app: config.name,
7
+ primary_region: config.region ?? 'iad',
8
+ build: { dockerfile: 'Dockerfile' },
9
+ http_service: {
10
+ internal_port: port,
11
+ force_https: true,
12
+ auto_stop_machines: true,
13
+ auto_start_machines: true,
14
+ },
15
+ vm: [
16
+ {
17
+ memory: config.memory ?? '256mb',
18
+ cpu_kind: 'shared',
19
+ cpus: 1,
20
+ },
21
+ ],
22
+ env: {
23
+ PORT: String(port),
24
+ MCP_TRANSPORT: 'http',
25
+ SERVER_NAME: config.name,
26
+ SERVER_VERSION: config.version,
27
+ },
28
+ };
29
+ }
30
+ export function generateDockerfile() {
31
+ let tgzFiles = [];
32
+ try {
33
+ tgzFiles = readdirSync('.').filter((f) => f.endsWith('.tgz'));
34
+ }
35
+ catch {
36
+ // ignore
37
+ }
38
+ const lines = [
39
+ 'FROM node:20-alpine',
40
+ 'WORKDIR /app',
41
+ 'COPY package*.json ./',
42
+ ...(tgzFiles.length > 0 ? [`COPY ${tgzFiles.join(' ')} ./`] : []),
43
+ 'RUN npm ci --omit=dev',
44
+ 'COPY dist ./dist',
45
+ 'COPY mcpster.config.json ./',
46
+ 'EXPOSE 3000',
47
+ 'CMD ["node", "dist/index.js"]',
48
+ ];
49
+ return lines.join('\n');
50
+ }
51
+ export async function deploy(config) {
52
+ const manifest = generateManifest(config);
53
+ const { writeFileSync } = await import('node:fs');
54
+ writeFileSync('fly.toml', toToml(manifest), 'utf8');
55
+ writeFileSync('Dockerfile', generateDockerfile(), 'utf8');
56
+ execSync('fly deploy', { encoding: 'utf8', stdio: 'inherit' });
57
+ const infoOutput = execSync(`fly status --app ${config.name} --json`, { encoding: 'utf8' });
58
+ const info = JSON.parse(infoOutput);
59
+ const url = info.Hostname ? `https://${info.Hostname}` : '';
60
+ return { url, target: 'fly', manifest };
61
+ }
62
+ function toToml(obj, indent = 0) {
63
+ const lines = [];
64
+ const pad = ' '.repeat(indent);
65
+ for (const [key, value] of Object.entries(obj)) {
66
+ if (value === null || value === undefined)
67
+ continue;
68
+ if (Array.isArray(value)) {
69
+ for (const item of value) {
70
+ lines.push(`${pad}[[${key}]]`);
71
+ lines.push(toToml(item, indent + 2));
72
+ }
73
+ }
74
+ else if (typeof value === 'object') {
75
+ lines.push(`${pad}[${key}]`);
76
+ lines.push(toToml(value, indent + 2));
77
+ }
78
+ else if (typeof value === 'string') {
79
+ lines.push(`${pad}${key} = "${value}"`);
80
+ }
81
+ else {
82
+ lines.push(`${pad}${key} = ${String(value)}`);
83
+ }
84
+ }
85
+ return lines.join('\n');
86
+ }
@@ -0,0 +1,19 @@
1
+ import type { DeployConfig, DeployResult } from './types.js';
2
+ export interface RailwayManifest {
3
+ '$schema': string;
4
+ build: {
5
+ builder: string;
6
+ };
7
+ deploy: {
8
+ startCommand: string;
9
+ healthcheckPath: string;
10
+ restartPolicyType: string;
11
+ };
12
+ environments: {
13
+ production: {
14
+ variables: Record<string, string>;
15
+ };
16
+ };
17
+ }
18
+ export declare function generateManifest(config: DeployConfig): RailwayManifest;
19
+ export declare function deploy(config: DeployConfig): Promise<DeployResult>;
@@ -0,0 +1,34 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function generateManifest(config) {
3
+ const port = config.port ?? 3000;
4
+ return {
5
+ '$schema': 'https://railway.app/railway.schema.json',
6
+ build: { builder: 'NIXPACKS' },
7
+ deploy: {
8
+ startCommand: `node dist/index.js`,
9
+ healthcheckPath: '/mcp',
10
+ restartPolicyType: 'ON_FAILURE',
11
+ },
12
+ environments: {
13
+ production: {
14
+ variables: {
15
+ PORT: String(port),
16
+ MCP_TRANSPORT: 'http',
17
+ SERVER_NAME: config.name,
18
+ SERVER_VERSION: config.version,
19
+ },
20
+ },
21
+ },
22
+ };
23
+ }
24
+ export async function deploy(config) {
25
+ const manifest = generateManifest(config);
26
+ const json = JSON.stringify(manifest, null, 2);
27
+ // Write railway.json then invoke CLI
28
+ const { writeFileSync } = await import('node:fs');
29
+ writeFileSync('railway.json', json, 'utf8');
30
+ const output = execSync('railway up --json', { encoding: 'utf8' });
31
+ const result = JSON.parse(output);
32
+ const url = result.url ?? result.deploymentUrl ?? '';
33
+ return { url, target: 'railway', manifest };
34
+ }
@@ -0,0 +1,18 @@
1
+ export type DeployTarget = 'railway' | 'fly' | 'cloudflare';
2
+ export interface DeployConfig {
3
+ name: string;
4
+ version: string;
5
+ port?: number;
6
+ region?: string;
7
+ memory?: string;
8
+ cpu?: string;
9
+ }
10
+ export interface DeployResult {
11
+ url: string;
12
+ target: DeployTarget;
13
+ manifest: unknown;
14
+ }
15
+ export interface DeployAdapter {
16
+ generateManifest(config: DeployConfig): unknown;
17
+ deploy(config: DeployConfig): Promise<DeployResult>;
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { createServer } from './server.js';
2
+ export type { McpsterServer, ServerConfig, HttpConfig, ToolDefinition, ResourceDefinition, PromptDefinition, SetupOptions, PermissionMode } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createServer } from './server.js';
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { PromptDefinition } from './types.js';
3
+ export declare function registerPrompt(sdk: McpServer, def: PromptDefinition): void;
package/dist/prompt.js ADDED
@@ -0,0 +1,16 @@
1
+ export function registerPrompt(sdk, def) {
2
+ sdk.registerPrompt(def.name, { description: def.description }, async (args) => {
3
+ try {
4
+ const text = await def.handler(args);
5
+ return {
6
+ messages: [{ role: 'user', content: { type: 'text', text } }],
7
+ };
8
+ }
9
+ catch (err) {
10
+ const message = err instanceof Error ? err.message : String(err);
11
+ return {
12
+ messages: [{ role: 'user', content: { type: 'text', text: message } }],
13
+ };
14
+ }
15
+ });
16
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ResourceDefinition } from './types.js';
3
+ export declare function registerResource(sdk: McpServer, def: ResourceDefinition): void;
@@ -0,0 +1,36 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ function parseUriParams(uri) {
3
+ return [...uri.matchAll(/\{(\w+)\}/g)].map(m => m[1]);
4
+ }
5
+ export function registerResource(sdk, def) {
6
+ const params = parseUriParams(def.uri);
7
+ if (params.length === 0) {
8
+ sdk.registerResource(def.uri, def.uri, { description: def.description }, async () => {
9
+ try {
10
+ const text = await def.resolver({});
11
+ return { contents: [{ uri: def.uri, text }] };
12
+ }
13
+ catch (err) {
14
+ const message = err instanceof Error ? err.message : String(err);
15
+ return { contents: [{ uri: def.uri, text: message }] };
16
+ }
17
+ });
18
+ }
19
+ else {
20
+ const template = new ResourceTemplate(def.uri, { list: undefined });
21
+ sdk.registerResource(def.uri, template, { description: def.description }, async (uri, variables) => {
22
+ try {
23
+ const extracted = {};
24
+ for (const [k, v] of Object.entries(variables)) {
25
+ extracted[k] = Array.isArray(v) ? v[0] : String(v);
26
+ }
27
+ const text = await def.resolver(extracted);
28
+ return { contents: [{ uri: uri.toString(), text }] };
29
+ }
30
+ catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return { contents: [{ uri: uri.toString(), text: message }] };
33
+ }
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerConfig, McpsterServer } from './types.js';
2
+ export declare function createServer(config: ServerConfig): McpsterServer;
package/dist/server.js ADDED
@@ -0,0 +1,51 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { connectStdio } from './transport/stdio.js';
3
+ import { connectHttp } from './transport/http.js';
4
+ import { registerTool } from './tool.js';
5
+ import { registerResource } from './resource.js';
6
+ import { registerPrompt } from './prompt.js';
7
+ import { applySetup } from './setup.js';
8
+ class McpsterServerImpl {
9
+ sdk;
10
+ config;
11
+ toolNames = [];
12
+ _stop;
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.sdk = new McpServer({
16
+ name: config.name,
17
+ version: config.version,
18
+ });
19
+ }
20
+ defineTool(def) {
21
+ registerTool(this.sdk, def);
22
+ this.toolNames.push(def.name);
23
+ return this;
24
+ }
25
+ defineResource(def) {
26
+ registerResource(this.sdk, def);
27
+ return this;
28
+ }
29
+ definePrompt(def) {
30
+ registerPrompt(this.sdk, def);
31
+ return this;
32
+ }
33
+ async setup(options) {
34
+ applySetup(this.config.name, this.toolNames, options);
35
+ return this;
36
+ }
37
+ async start() {
38
+ if (this.config.transport === 'http') {
39
+ this._stop = await connectHttp(this.sdk, this.config.http);
40
+ }
41
+ else {
42
+ this._stop = await connectStdio(this.sdk);
43
+ }
44
+ }
45
+ async stop() {
46
+ await this._stop?.();
47
+ }
48
+ }
49
+ export function createServer(config) {
50
+ return new McpsterServerImpl(config);
51
+ }
@@ -0,0 +1,2 @@
1
+ import type { SetupOptions } from './types.js';
2
+ export declare function applySetup(serverName: string, toolNames: string[], options?: SetupOptions): void;
package/dist/setup.js ADDED
@@ -0,0 +1,34 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { resolve } from 'path';
4
+ function claudeJsonPath() {
5
+ return resolve(homedir(), '.claude.json');
6
+ }
7
+ function readClaudeJson() {
8
+ try {
9
+ return JSON.parse(readFileSync(claudeJsonPath(), 'utf8'));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function writeClaudeJson(data) {
16
+ writeFileSync(claudeJsonPath(), JSON.stringify(data, null, 2) + '\n', 'utf8');
17
+ }
18
+ export function applySetup(serverName, toolNames, options = {}) {
19
+ const projectPath = resolve(options.projectPath ?? process.cwd());
20
+ const permissions = options.permissions ?? 'restrictive';
21
+ const claude = readClaudeJson();
22
+ const projects = (claude.projects ?? {});
23
+ const project = projects[projectPath] ?? {};
24
+ const allowedTools = permissions === 'permissive'
25
+ ? toolNames.map(name => `${serverName}:${name}`)
26
+ : [];
27
+ projects[projectPath] = { ...project, allowedTools };
28
+ claude.projects = projects;
29
+ writeClaudeJson(claude);
30
+ console.error(`[mcpster] setup complete — permissions: ${permissions}`);
31
+ if (allowedTools.length > 0) {
32
+ console.error(`[mcpster] allowed tools: ${allowedTools.join(', ')}`);
33
+ }
34
+ }
package/dist/tool.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ZodSchema } from 'zod';
3
+ import type { ToolDefinition } from './types.js';
4
+ export declare function registerTool<T extends ZodSchema>(sdk: McpServer, def: ToolDefinition<T>): void;
package/dist/tool.js ADDED
@@ -0,0 +1,17 @@
1
+ export function registerTool(sdk, def) {
2
+ const shape = def.schema.shape;
3
+ sdk.registerTool(def.name, {
4
+ description: def.description,
5
+ inputSchema: shape,
6
+ }, async (args) => {
7
+ try {
8
+ const result = await def.handler(args);
9
+ const text = typeof result === 'string' ? result : JSON.stringify(result);
10
+ return { content: [{ type: 'text', text }] };
11
+ }
12
+ catch (err) {
13
+ const message = err instanceof Error ? err.message : String(err);
14
+ return { content: [{ type: 'text', text: message }], isError: true };
15
+ }
16
+ });
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { HttpConfig } from '../types.js';
3
+ export declare function connectHttp(server: McpServer, config?: HttpConfig): Promise<() => Promise<void>>;
@@ -0,0 +1,228 @@
1
+ import express from 'express';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
7
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
8
+ function createClientsStore(clientsFile) {
9
+ const clients = new Map();
10
+ if (clientsFile) {
11
+ try {
12
+ const raw = readFileSync(clientsFile, 'utf8');
13
+ const stored = JSON.parse(raw);
14
+ for (const c of stored)
15
+ clients.set(c.client_id, c);
16
+ }
17
+ catch {
18
+ // File doesn't exist yet — start empty
19
+ }
20
+ }
21
+ function persist() {
22
+ if (!clientsFile)
23
+ return;
24
+ try {
25
+ mkdirSync(dirname(clientsFile), { recursive: true });
26
+ writeFileSync(clientsFile, JSON.stringify([...clients.values()], null, 2), 'utf8');
27
+ }
28
+ catch (err) {
29
+ console.error('[mcpster] Failed to persist OAuth clients:', err);
30
+ }
31
+ }
32
+ return {
33
+ getClient: (id) => clients.get(id) ?? undefined,
34
+ registerClient: (client) => {
35
+ // Always treat dynamically registered clients as public (no client_secret).
36
+ // This allows clients like Claude.ai that use PKCE and never send a secret
37
+ // to complete the token exchange, regardless of what they sent at registration.
38
+ const { client_secret: _secret, client_secret_expires_at: _exp, ...rest } = client;
39
+ const full = {
40
+ redirect_uris: [],
41
+ ...rest,
42
+ token_endpoint_auth_method: 'none',
43
+ client_id: randomUUID(),
44
+ client_id_issued_at: Math.floor(Date.now() / 1000),
45
+ };
46
+ clients.set(full.client_id, full);
47
+ persist();
48
+ return full;
49
+ },
50
+ };
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // OAuth 2.1 provider (PKCE, dynamic client registration)
54
+ // ---------------------------------------------------------------------------
55
+ function createOAuthProvider(clientsFile) {
56
+ const clientsStore = createClientsStore(clientsFile);
57
+ const authCodes = new Map();
58
+ const tokens = new Map();
59
+ const refreshTokens = new Map();
60
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
61
+ return {
62
+ get clientsStore() { return clientsStore; },
63
+ async authorize(client, params, res) {
64
+ const code = randomUUID();
65
+ authCodes.set(code, {
66
+ clientId: client.client_id,
67
+ codeChallenge: params.codeChallenge,
68
+ redirectUri: params.redirectUri,
69
+ scopes: params.scopes ?? [],
70
+ });
71
+ res.send(`<!DOCTYPE html>
72
+ <html lang="en">
73
+ <head>
74
+ <meta charset="utf-8">
75
+ <title>Authorize — ${esc(client.client_name ?? client.client_id)}</title>
76
+ <style>
77
+ body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 20px; }
78
+ h2 { margin-bottom: 8px; }
79
+ p { color: #555; margin-bottom: 24px; }
80
+ button { padding: 10px 20px; border-radius: 6px; font-size: 15px; cursor: pointer; border: 1px solid #ccc; }
81
+ .approve { background: #0070f3; color: #fff; border-color: #0070f3; margin-right: 10px; }
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <h2>Authorization Request</h2>
86
+ <p><strong>${esc(client.client_name ?? client.client_id)}</strong> is requesting access to this MCP server.</p>
87
+ <form method="POST" action="/approve">
88
+ <input type="hidden" name="code" value="${esc(code)}">
89
+ <input type="hidden" name="redirect_uri" value="${esc(params.redirectUri)}">
90
+ <input type="hidden" name="state" value="${esc(params.state ?? '')}">
91
+ <button class="approve" type="submit">Approve</button>
92
+ <button type="button" onclick="history.back()">Deny</button>
93
+ </form>
94
+ </body>
95
+ </html>`);
96
+ },
97
+ async challengeForAuthorizationCode(client, code) {
98
+ const entry = authCodes.get(code);
99
+ if (!entry || entry.clientId !== client.client_id)
100
+ throw new Error('Invalid authorization code');
101
+ return entry.codeChallenge;
102
+ },
103
+ async exchangeAuthorizationCode(client, code) {
104
+ const entry = authCodes.get(code);
105
+ if (!entry || entry.clientId !== client.client_id)
106
+ throw new Error('Invalid authorization code');
107
+ authCodes.delete(code);
108
+ const accessToken = randomUUID();
109
+ const refreshToken = randomUUID();
110
+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
111
+ tokens.set(accessToken, { token: accessToken, clientId: client.client_id, scopes: entry.scopes, expiresAt, refreshToken });
112
+ refreshTokens.set(refreshToken, accessToken);
113
+ return { access_token: accessToken, token_type: 'bearer', expires_in: 3600, refresh_token: refreshToken };
114
+ },
115
+ async exchangeRefreshToken(client, oldRefreshToken) {
116
+ const oldAccess = refreshTokens.get(oldRefreshToken);
117
+ if (!oldAccess)
118
+ throw new Error('Invalid refresh token');
119
+ const old = tokens.get(oldAccess);
120
+ if (!old)
121
+ throw new Error('Invalid refresh token');
122
+ const accessToken = randomUUID();
123
+ const newRefresh = randomUUID();
124
+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
125
+ tokens.delete(oldAccess);
126
+ refreshTokens.delete(oldRefreshToken);
127
+ tokens.set(accessToken, { token: accessToken, clientId: client.client_id, scopes: old.scopes, expiresAt, refreshToken: newRefresh });
128
+ refreshTokens.set(newRefresh, accessToken);
129
+ return { access_token: accessToken, token_type: 'bearer', expires_in: 3600, refresh_token: newRefresh };
130
+ },
131
+ async verifyAccessToken(token) {
132
+ const info = tokens.get(token);
133
+ if (!info)
134
+ throw new Error('Invalid token');
135
+ if (info.expiresAt < Math.floor(Date.now() / 1000))
136
+ throw new Error('Token expired');
137
+ return info;
138
+ },
139
+ };
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // HTTP transport with OAuth 2.1
143
+ // ---------------------------------------------------------------------------
144
+ export async function connectHttp(server, config) {
145
+ const port = config?.port ?? 3000;
146
+ const path = config?.path ?? '/mcp';
147
+ // baseUrl drives OAuth metadata (issuer, token endpoint, etc.).
148
+ // Priority: config.baseUrl → BASE_URL env → http://localhost:<port>
149
+ const envBaseUrl = process.env.BASE_URL;
150
+ const rawBaseUrl = config?.baseUrl || (envBaseUrl?.startsWith('http') ? envBaseUrl : undefined) || `http://localhost:${port}`;
151
+ const baseUrl = new URL(rawBaseUrl);
152
+ const authEnabled = config?.auth ?? false;
153
+ const app = express();
154
+ // Behind a reverse proxy (Fly.io, Railway, etc.) there is exactly one hop.
155
+ app.set('trust proxy', 1);
156
+ app.use(express.json());
157
+ app.use(express.urlencoded({ extended: false }));
158
+ // Request logging
159
+ app.use((req, _res, next) => {
160
+ console.log(`[mcpster] ${req.method} ${req.path}`, req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : '');
161
+ next();
162
+ });
163
+ if (authEnabled) {
164
+ if (!config?.clientsFile && !process.env.MCPSTER_CLIENTS_FILE) {
165
+ console.warn('[mcpster] Warning: running HTTP transport without clientsFile — registered OAuth clients ' +
166
+ 'are stored in memory only and will be lost on restart. Set http.clientsFile in ' +
167
+ 'mcpster.config.json (e.g. "/data/oauth-clients.json") and mount a persistent volume.');
168
+ }
169
+ const provider = createOAuthProvider(config?.clientsFile ?? process.env.MCPSTER_CLIENTS_FILE);
170
+ // OAuth discovery + registration + token endpoints
171
+ app.use(mcpAuthRouter({ provider, issuerUrl: baseUrl }));
172
+ // Approve endpoint — browser page shown during the OAuth flow
173
+ app.post('/approve', (req, res) => {
174
+ const { code, redirect_uri, state } = req.body;
175
+ if (!code || !redirect_uri) {
176
+ res.status(400).send('Missing code or redirect_uri');
177
+ return;
178
+ }
179
+ const url = new URL(redirect_uri);
180
+ url.searchParams.set('code', code);
181
+ if (state)
182
+ url.searchParams.set('state', state);
183
+ res.redirect(url.toString());
184
+ });
185
+ // MCP endpoint — protected by bearer token
186
+ // resourceMetadataUrl tells clients where to find OAuth discovery metadata (RFC 9728)
187
+ const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', baseUrl).href;
188
+ app.all(path, requireBearerAuth({ verifier: provider, resourceMetadataUrl }), async (req, res) => {
189
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: config?.enableJsonResponse });
190
+ try {
191
+ await server.connect(transport);
192
+ await transport.handleRequest(req, res, req.body);
193
+ }
194
+ catch {
195
+ if (!res.headersSent)
196
+ res.status(500).end();
197
+ }
198
+ finally {
199
+ await transport.close();
200
+ }
201
+ });
202
+ }
203
+ else {
204
+ // No auth — MCP endpoint is open
205
+ app.all(path, async (req, res) => {
206
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: config?.enableJsonResponse });
207
+ try {
208
+ await server.connect(transport);
209
+ await transport.handleRequest(req, res, req.body);
210
+ }
211
+ catch {
212
+ if (!res.headersSent)
213
+ res.status(500).end();
214
+ }
215
+ finally {
216
+ await transport.close();
217
+ }
218
+ });
219
+ }
220
+ const httpServer = app.listen(port);
221
+ await new Promise((resolve, reject) => {
222
+ httpServer.once('listening', resolve);
223
+ httpServer.once('error', reject);
224
+ });
225
+ return () => new Promise((resolve, reject) => {
226
+ httpServer.close((err) => (err ? reject(err) : resolve()));
227
+ });
228
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function connectStdio(server: McpServer): Promise<() => Promise<void>>;
@@ -0,0 +1,6 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ export async function connectStdio(server) {
3
+ const transport = new StdioServerTransport();
4
+ await server.connect(transport);
5
+ return () => Promise.resolve();
6
+ }
@@ -0,0 +1,45 @@
1
+ import type { z, ZodSchema } from 'zod';
2
+ export interface HttpConfig {
3
+ port?: number;
4
+ path?: string;
5
+ baseUrl?: string;
6
+ auth?: boolean;
7
+ clientsFile?: string;
8
+ enableJsonResponse?: boolean;
9
+ }
10
+ export interface ServerConfig {
11
+ name: string;
12
+ version: string;
13
+ scope?: string;
14
+ transport?: 'stdio' | 'http';
15
+ http?: HttpConfig;
16
+ }
17
+ export interface ToolDefinition<T extends ZodSchema = ZodSchema> {
18
+ name: string;
19
+ description: string;
20
+ schema: T;
21
+ handler: (input: z.infer<T>) => Promise<unknown>;
22
+ }
23
+ export interface ResourceDefinition {
24
+ uri: string;
25
+ description: string;
26
+ resolver: (params: Record<string, string>) => Promise<string>;
27
+ }
28
+ export interface PromptDefinition {
29
+ name: string;
30
+ description?: string;
31
+ handler: (args: Record<string, string>) => Promise<string>;
32
+ }
33
+ export type PermissionMode = 'permissive' | 'restrictive';
34
+ export interface SetupOptions {
35
+ permissions?: PermissionMode;
36
+ projectPath?: string;
37
+ }
38
+ export interface McpsterServer {
39
+ defineTool<T extends ZodSchema>(def: ToolDefinition<T>): McpsterServer;
40
+ defineResource(def: ResourceDefinition): McpsterServer;
41
+ definePrompt(def: PromptDefinition): McpsterServer;
42
+ setup(options?: SetupOptions): Promise<McpsterServer>;
43
+ start(): Promise<void>;
44
+ stop(): Promise<void>;
45
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@ruco-ai/mcpster",
3
+ "version": "0.2.5",
4
+ "description": "Agnostic TypeScript SDK for building MCP servers",
5
+ "license": "MIT",
6
+ "author": "ruco",
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "sdk",
11
+ "typescript",
12
+ "llm",
13
+ "ai"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/ruco-ai/mcpster"
18
+ },
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "import": "./dist/index.js",
25
+ "types": "./dist/index.d.ts"
26
+ }
27
+ },
28
+ "bin": {
29
+ "mcpster-deploy": "./dist/deploy/cli.js"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "dev": "tsc --watch",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "prepublishOnly": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.29.0",
43
+ "express": "^5",
44
+ "zod": "^3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/express": "^5.0.6",
48
+ "@types/node": "^25.5.2",
49
+ "typescript": "^5",
50
+ "vitest": "^4.1.2"
51
+ }
52
+ }