skedyul 0.1.0

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,219 @@
1
+ # skedyul
2
+
3
+ Reusable helpers for building Model Context Protocol (MCP) runtimes in Node.js. This package powers the dedicated and serverless starters by exposing registry typing, environment helpers, and a shared `server.create` factory that wires JSON-RPC tooling, request counting, and runtime-specific adapters.
4
+
5
+ ## What's inside
6
+
7
+ - `ToolContext`, `ToolHandler`, and supporting typings so registries can stay strongly typed.
8
+ - `server.create` accepts a config object (metadata, compute layer, rate limits, CORS) and a tool registry, then exposes a `listen()` API for HTTP servers and a Lambda-style `handler()` respectively.
9
+ - Request counting, TTL, env merging (`MCP_ENV_JSON` + `MCP_ENV`), JSON-RPC transport, and health metadata so each runtime behaves consistently.
10
+
11
+ ## Billing contract
12
+
13
+ Every `ToolHandler` must return both its result payload (`output`) and a `billing` object describing the credits consumed:
14
+
15
+ ```ts
16
+ export const helloWorld: ToolHandler<HelloWorldInput, HelloWorldOutput> = async ({
17
+ input,
18
+ context,
19
+ }) => {
20
+ // Do your work here...
21
+ const credits = calculateCredits(input, context) // e.g. characters × rate
22
+
23
+ return {
24
+ output: {
25
+ message: `Hello, ${input.name ?? 'world'}`,
26
+ environmentName: context.env.SKEDYUL_ENV ?? 'local',
27
+ },
28
+ billing: { credits },
29
+ }
30
+ }
31
+ ```
32
+
33
+ Servers attach that `billing` data to every MCP response, so callers always know the final credit cost. Since pricing may depend on runtime data (like message length), compute credits inside the registry or another secure helper that can read your pricing tables, rather than in the shared server logic.
34
+
35
+ ## Estimate endpoint
36
+
37
+ Both transports expose a dedicated `POST /estimate` endpoint that reuses the same registry to calculate projected billing without executing charged work. Callers supply a tool name and inputs, and the server forwards the request to the registry with `context.mode === 'estimate'`. The response contains only the `billing` block:
38
+
39
+ ```json
40
+ POST /estimate
41
+ {
42
+ "name": "hello-world",
43
+ "inputs": { "name": "demo" }
44
+ }
45
+
46
+ 200 OK
47
+ {
48
+ "billing": { "credits": 4 }
49
+ }
50
+ ```
51
+
52
+ Treat `/estimate` as an authentication-protected route (if your runtime supports auth) because it exposes pricing metadata. Keep your pricing tables and cost calculations in a guarded part of your stack, and never return internal secrets through `estimate`.
53
+
54
+ ## Core API hooks
55
+
56
+ For integration-specific RPCs that belong to your platform rather than a tool, provide a `coreApi` implementation when calling `server.create`. The property accepts:
57
+
58
+ - `service`: an object implementing `createCommunicationChannel`, `updateCommunicationChannel`, `deleteCommunicationChannel`, `getCommunicationChannel`, `getCommunicationChannels`, and `sendMessage`.
59
+ - `webhookHandler`: an optional `(request: WebhookRequest) => Promise<WebhookResponse>` callback that will receive forwarded HTTP webhooks.
60
+
61
+ The MCP server exposes `POST /core` (with `{ method, params }`) and `POST /core/webhook` for these operations. They never appear under `tools/list` unlesstaken explicit MCP tooling—they are separate transport-level handlers and do not count against tool request limits. Make sure your service returns the structured channel/message data defined in `src/core/types.ts` so the responses stay consistent, and guard `/core`/`/core/webhook` with your platform’s preferred authentication if you surface them externally.
62
+
63
+ ## Higher-level helpers
64
+
65
+ While `server.create` hosts the MCP surface, `skedyul.workplace` and `skedyul.communicationChannel` expose dedicated helpers that talk to `/core` for the same workplace and channel metadata. Each helper returns the typed objects defined in `src/core/types.ts` so integrations can look up a channel/workplace pair before acting on an incoming webhook without manually composing the RPC payload or dealing with authentication.
66
+
67
+ The helpers currently provide:
68
+
69
+ - `workplace.list(filter?: Record<string, unknown>)`
70
+ - `workplace.get(id: string)`
71
+ - `communicationChannel.list(filter?: Record<string, unknown>)`
72
+ - `communicationChannel.get(id: string)`
73
+
74
+ Example:
75
+
76
+ ```ts
77
+ import { communicationChannel, workplace } from 'skedyul'
78
+
79
+ const [channel] = await communicationChannel.list({
80
+ filter: { identifierValue: '+15551234567' },
81
+ })
82
+
83
+ const owner = await workplace.get(channel.workplaceId)
84
+ ```
85
+
86
+ Use these helpers for internal wiring—like pulling the correct workplace for a webhook—without touching the MCP tooling surface directly.
87
+
88
+ ## Installation
89
+
90
+ ```bash
91
+ npm install skedyul
92
+ ```
93
+
94
+ ## Usage
95
+
96
+ ### Define your registry
97
+
98
+ Each tool should follow the shared handler signature:
99
+
100
+ ```ts
101
+ import type { ToolContext, ToolHandler } from 'skedyul'
102
+
103
+ export interface HelloWorldInput {
104
+ name?: string
105
+ }
106
+
107
+ export interface HelloWorldOutput {
108
+ message: string
109
+ environmentName: string
110
+ }
111
+
112
+ export const helloWorld: ToolHandler<HelloWorldInput, HelloWorldOutput> = async ({
113
+ input,
114
+ context,
115
+ }) => {
116
+ const name = input.name?.trim() || 'world'
117
+ const environmentName = context.env.SKEDYUL_ENV ?? 'local'
118
+
119
+ return {
120
+ message: `Hello, ${name}!`,
121
+ environmentName,
122
+ }
123
+ }
124
+
125
+ export const registry = {
126
+ 'hello-world': helloWorld,
127
+ }
128
+ ```
129
+
130
+ ### Dedicated server
131
+
132
+ ```ts
133
+ import { server } from 'skedyul'
134
+ import { registry } from './registry'
135
+
136
+ const mcpServer = server.create(
137
+ {
138
+ computeLayer: 'dedicated',
139
+ metadata: {
140
+ name: 'my-dedicated-server',
141
+ version: '1.0.0',
142
+ },
143
+ defaultPort: 3000,
144
+ maxRequests: 1000,
145
+ ttlExtendSeconds: 3600,
146
+ },
147
+ registry,
148
+ )
149
+
150
+ await mcpServer.listen()
151
+ ```
152
+
153
+ ### Serverless handler
154
+
155
+ ```ts
156
+ import { server } from 'skedyul'
157
+ import { registry } from './registry'
158
+
159
+ const mcpServer = server.create(
160
+ {
161
+ computeLayer: 'serverless',
162
+ metadata: {
163
+ name: 'my-serverless-mcp',
164
+ version: '1.0.0',
165
+ },
166
+ cors: {
167
+ allowOrigin: '*',
168
+ },
169
+ },
170
+ registry,
171
+ )
172
+
173
+ export const handler = mcpServer.handler
174
+ ```
175
+
176
+ ## Configuration guide
177
+
178
+ - **`computeLayer`**: Choose `dedicated` to expose an HTTP server (`listen`) or `serverless` to get a Lambda handler (`handler`).
179
+ - **`metadata`**: Used for the MCP server versioning payload.
180
+ - **`maxRequests` / `ttlExtendSeconds`**: Control request capping logic that triggers a graceful shutdown (dedicated) or throttles health stats.
181
+ - **`cors`**: Serverless handlers automatically add the configured CORS headers to every response.
182
+ - **Runtime env overrides**: `MCP_ENV_JSON` (build-time) and `MCP_ENV` (container runtime) merge into `process.env` before every request, while request-level `env` arguments temporarily override env vars per tool call.
183
+
184
+ ## Health metadata
185
+
186
+ Both adapters expose `getHealthStatus()`, returning:
187
+
188
+ - `status`: always `running` while the process is alive.
189
+ - `requests`, `maxRequests`, `requestsRemaining`.
190
+ - `lastRequestTime`, `ttlExtendSeconds`.
191
+ - `runtime`: string label (`dedicated` or `serverless`).
192
+ - `tools`: list of registered tool names.
193
+
194
+ ## Development
195
+
196
+ - `npm run build` compiles the TypeScript sources into `dist/`.
197
+ - `npm test` rebuilds the package and runs `tests/server.test.js` against the compiled output.
198
+ - `npm run lint` (if added later) should validate formatting/typing.
199
+
200
+ ## Publishing
201
+
202
+ Before publishing:
203
+
204
+ 1. Run `npm run build`.
205
+ 2. Verify `dist/index.js` and `.d.ts` exist.
206
+ 3. Ensure `package.json` metadata (name, version, description, repository, author, license) matches the npm listing.
207
+
208
+ Use `npm publish --access public` once the package is ready; the `files` array already limits the tarball to `dist/`.
209
+
210
+ ## Contributing
211
+
212
+ Contributions should:
213
+
214
+ 1. Follow the TypeScript style (strict types, async/await, try/catch).
215
+ 2. Keep MCP transports lean and share logic in `src/server.ts`.
216
+ 3. Add unit tests under `tests/` and run them via `npm test`.
217
+
218
+ Open a PR with a clear summary so the release process can verify `dist/` artifacts before publishing.
219
+
@@ -0,0 +1,13 @@
1
+ import type { CommunicationChannel, Workplace } from './types';
2
+ type ListArgs = {
3
+ filter?: Record<string, unknown>;
4
+ };
5
+ export declare const workplace: {
6
+ list(args?: ListArgs): Promise<Workplace[]>;
7
+ get(id: string): Promise<Workplace>;
8
+ };
9
+ export declare const communicationChannel: {
10
+ list(filter?: Record<string, unknown>): Promise<CommunicationChannel[]>;
11
+ get(id: string): Promise<CommunicationChannel>;
12
+ };
13
+ export {};
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.communicationChannel = exports.workplace = void 0;
4
+ const CORE_BASE = process.env.SKEDYUL_NODE_URL ?? '';
5
+ async function callCore(method, params) {
6
+ const response = await fetch(`${CORE_BASE}/core`, {
7
+ method: 'POST',
8
+ headers: {
9
+ 'Content-Type': 'application/json',
10
+ },
11
+ body: JSON.stringify({ method, params }),
12
+ });
13
+ const payload = await response.json();
14
+ if (!response.ok) {
15
+ throw new Error(payload?.error?.message ?? `Core API error (${response.status})`);
16
+ }
17
+ return payload;
18
+ }
19
+ exports.workplace = {
20
+ async list(args) {
21
+ const payload = await callCore('workplace.list', args?.filter ? { filter: args.filter } : undefined);
22
+ return payload.workplaces;
23
+ },
24
+ async get(id) {
25
+ const payload = await callCore('workplace.get', { id });
26
+ return payload.workplace;
27
+ },
28
+ };
29
+ exports.communicationChannel = {
30
+ async list(filter) {
31
+ const payload = await callCore('communicationChannel.list', filter ? { filter } : undefined);
32
+ return payload.channels;
33
+ },
34
+ async get(id) {
35
+ const payload = await callCore('communicationChannel.get', { id });
36
+ return payload.channel;
37
+ },
38
+ };
@@ -0,0 +1,38 @@
1
+ import type { CommunicationChannel, CommunicationService, Message, WebhookRequest, WebhookResponse } from './types';
2
+ declare class CoreApiService {
3
+ private service?;
4
+ private webhookHandler?;
5
+ register(service: CommunicationService): void;
6
+ getService(): CommunicationService | undefined;
7
+ setWebhookHandler(handler: (request: WebhookRequest) => Promise<WebhookResponse>): void;
8
+ dispatchWebhook(request: WebhookRequest): Promise<WebhookResponse>;
9
+ callCreateChannel(channel: CommunicationChannel): Promise<{
10
+ channel: CommunicationChannel;
11
+ } | undefined>;
12
+ callUpdateChannel(channel: CommunicationChannel): Promise<{
13
+ channel: CommunicationChannel;
14
+ } | undefined>;
15
+ callDeleteChannel(id: string): Promise<{
16
+ success: boolean;
17
+ } | undefined>;
18
+ callGetChannel(id: string): Promise<{
19
+ channel: CommunicationChannel;
20
+ } | undefined>;
21
+ callListChannels(): Promise<{
22
+ channels: CommunicationChannel[];
23
+ } | undefined>;
24
+ callGetWorkplace(id: string): Promise<{
25
+ workplace: import("./types").Workplace;
26
+ } | undefined>;
27
+ callListWorkplaces(): Promise<{
28
+ workplaces: import("./types").Workplace[];
29
+ } | undefined>;
30
+ callSendMessage(args: {
31
+ message: Message;
32
+ communicationChannel: CommunicationChannel;
33
+ }): Promise<{
34
+ message: Message;
35
+ } | undefined>;
36
+ }
37
+ export declare const coreApiService: CoreApiService;
38
+ export {};
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.coreApiService = void 0;
4
+ class CoreApiService {
5
+ register(service) {
6
+ this.service = service;
7
+ }
8
+ getService() {
9
+ return this.service;
10
+ }
11
+ setWebhookHandler(handler) {
12
+ this.webhookHandler = handler;
13
+ }
14
+ async dispatchWebhook(request) {
15
+ if (!this.webhookHandler) {
16
+ return { status: 404 };
17
+ }
18
+ return this.webhookHandler(request);
19
+ }
20
+ async callCreateChannel(channel) {
21
+ return this.service?.createCommunicationChannel(channel);
22
+ }
23
+ async callUpdateChannel(channel) {
24
+ return this.service?.updateCommunicationChannel(channel);
25
+ }
26
+ async callDeleteChannel(id) {
27
+ return this.service?.deleteCommunicationChannel(id);
28
+ }
29
+ async callGetChannel(id) {
30
+ return this.service?.getCommunicationChannel(id);
31
+ }
32
+ async callListChannels() {
33
+ return this.service?.getCommunicationChannels();
34
+ }
35
+ async callGetWorkplace(id) {
36
+ return this.service?.getWorkplace(id);
37
+ }
38
+ async callListWorkplaces() {
39
+ return this.service?.listWorkplaces();
40
+ }
41
+ async callSendMessage(args) {
42
+ return this.service?.sendMessage(args);
43
+ }
44
+ }
45
+ exports.coreApiService = new CoreApiService();
@@ -0,0 +1,63 @@
1
+ export interface CommunicationChannel {
2
+ id: string;
3
+ name: string;
4
+ type: 'sms' | 'whatsapp' | 'email';
5
+ createdAt: string;
6
+ metadata?: Record<string, unknown>;
7
+ }
8
+ export interface Workplace {
9
+ id: string;
10
+ name: string;
11
+ createdAt: string;
12
+ metadata?: Record<string, unknown>;
13
+ }
14
+ export interface Message {
15
+ id: string;
16
+ channelId: string;
17
+ body: string;
18
+ sentAt: string;
19
+ metadata?: Record<string, unknown>;
20
+ }
21
+ export interface WebhookRequest {
22
+ method: string;
23
+ headers: Record<string, string>;
24
+ body?: unknown;
25
+ query?: Record<string, string | undefined>;
26
+ }
27
+ export interface WebhookResponse {
28
+ status: number;
29
+ body?: unknown;
30
+ }
31
+ export interface CommunicationService {
32
+ createCommunicationChannel(channel: CommunicationChannel): Promise<{
33
+ channel: CommunicationChannel;
34
+ }>;
35
+ updateCommunicationChannel(channel: CommunicationChannel): Promise<{
36
+ channel: CommunicationChannel;
37
+ }>;
38
+ deleteCommunicationChannel(id: string): Promise<{
39
+ success: boolean;
40
+ }>;
41
+ getCommunicationChannel(id: string): Promise<{
42
+ channel: CommunicationChannel;
43
+ }>;
44
+ getCommunicationChannels(): Promise<{
45
+ channels: CommunicationChannel[];
46
+ }>;
47
+ sendMessage(args: {
48
+ message: Message;
49
+ communicationChannel: CommunicationChannel;
50
+ }): Promise<{
51
+ message: Message;
52
+ }>;
53
+ getWorkplace(id: string): Promise<{
54
+ workplace: Workplace;
55
+ }>;
56
+ listWorkplaces(): Promise<{
57
+ workplaces: Workplace[];
58
+ }>;
59
+ }
60
+ export interface CoreApiConfig {
61
+ service: CommunicationService;
62
+ webhookHandler?: (request: WebhookRequest) => Promise<WebhookResponse>;
63
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export { server } from './server';
3
+ export { workplace, communicationChannel } from './core/client';
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.communicationChannel = exports.workplace = exports.server = void 0;
18
+ __exportStar(require("./types"), exports);
19
+ var server_1 = require("./server");
20
+ Object.defineProperty(exports, "server", { enumerable: true, get: function () { return server_1.server; } });
21
+ var client_1 = require("./core/client");
22
+ Object.defineProperty(exports, "workplace", { enumerable: true, get: function () { return client_1.workplace; } });
23
+ Object.defineProperty(exports, "communicationChannel", { enumerable: true, get: function () { return client_1.communicationChannel; } });
@@ -0,0 +1,5 @@
1
+ import type { SkedyulServerConfig, SkedyulServerInstance, ToolRegistry } from './types';
2
+ export declare function createSkedyulServer(config: SkedyulServerConfig, registry: ToolRegistry): SkedyulServerInstance;
3
+ export declare const server: {
4
+ create: typeof createSkedyulServer;
5
+ };
package/dist/server.js ADDED
@@ -0,0 +1,743 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.server = void 0;
7
+ exports.createSkedyulServer = createSkedyulServer;
8
+ const http_1 = __importDefault(require("http"));
9
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
10
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
11
+ const service_1 = require("./core/service");
12
+ function normalizeBilling(billing) {
13
+ if (!billing || typeof billing.credits !== 'number') {
14
+ return { credits: 0 };
15
+ }
16
+ return billing;
17
+ }
18
+ function parseJsonRecord(value) {
19
+ if (!value) {
20
+ return {};
21
+ }
22
+ try {
23
+ return JSON.parse(value);
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ function parseNumberEnv(value) {
30
+ if (!value) {
31
+ return null;
32
+ }
33
+ const parsed = Number.parseInt(value, 10);
34
+ return Number.isNaN(parsed) ? null : parsed;
35
+ }
36
+ function mergeRuntimeEnv() {
37
+ const bakedEnv = parseJsonRecord(process.env.MCP_ENV_JSON);
38
+ const runtimeEnv = parseJsonRecord(process.env.MCP_ENV);
39
+ const merged = { ...bakedEnv, ...runtimeEnv };
40
+ Object.assign(process.env, merged);
41
+ }
42
+ async function handleCoreMethod(method, params) {
43
+ const service = service_1.coreApiService.getService();
44
+ if (!service) {
45
+ return {
46
+ status: 404,
47
+ payload: { error: 'Core API service not configured' },
48
+ };
49
+ }
50
+ if (method === 'createCommunicationChannel') {
51
+ if (!params?.channel) {
52
+ return { status: 400, payload: { error: 'channel is required' } };
53
+ }
54
+ const channel = params.channel;
55
+ const result = await service_1.coreApiService.callCreateChannel(channel);
56
+ if (!result) {
57
+ return {
58
+ status: 500,
59
+ payload: { error: 'Core API service did not respond' },
60
+ };
61
+ }
62
+ return { status: 200, payload: result };
63
+ }
64
+ if (method === 'updateCommunicationChannel') {
65
+ if (!params?.channel) {
66
+ return { status: 400, payload: { error: 'channel is required' } };
67
+ }
68
+ const channel = params.channel;
69
+ const result = await service_1.coreApiService.callUpdateChannel(channel);
70
+ if (!result) {
71
+ return {
72
+ status: 500,
73
+ payload: { error: 'Core API service did not respond' },
74
+ };
75
+ }
76
+ return { status: 200, payload: result };
77
+ }
78
+ if (method === 'deleteCommunicationChannel') {
79
+ if (!params?.id || typeof params.id !== 'string') {
80
+ return { status: 400, payload: { error: 'id is required' } };
81
+ }
82
+ const result = await service_1.coreApiService.callDeleteChannel(params.id);
83
+ if (!result) {
84
+ return {
85
+ status: 500,
86
+ payload: { error: 'Core API service did not respond' },
87
+ };
88
+ }
89
+ return { status: 200, payload: result };
90
+ }
91
+ if (method === 'getCommunicationChannel') {
92
+ if (!params?.id || typeof params.id !== 'string') {
93
+ return { status: 400, payload: { error: 'id is required' } };
94
+ }
95
+ const result = await service_1.coreApiService.callGetChannel(params.id);
96
+ if (!result) {
97
+ return {
98
+ status: 404,
99
+ payload: { error: 'Channel not found' },
100
+ };
101
+ }
102
+ return { status: 200, payload: result };
103
+ }
104
+ if (method === 'getCommunicationChannels') {
105
+ const result = await service_1.coreApiService.callListChannels();
106
+ if (!result) {
107
+ return {
108
+ status: 500,
109
+ payload: { error: 'Core API service did not respond' },
110
+ };
111
+ }
112
+ return { status: 200, payload: result };
113
+ }
114
+ if (method === 'communicationChannel.list') {
115
+ const result = await service_1.coreApiService.callListChannels();
116
+ if (!result) {
117
+ return {
118
+ status: 500,
119
+ payload: { error: 'Core API service did not respond' },
120
+ };
121
+ }
122
+ return { status: 200, payload: result };
123
+ }
124
+ if (method === 'communicationChannel.get') {
125
+ if (!params?.id || typeof params.id !== 'string') {
126
+ return { status: 400, payload: { error: 'id is required' } };
127
+ }
128
+ const result = await service_1.coreApiService.callGetChannel(params.id);
129
+ if (!result) {
130
+ return {
131
+ status: 404,
132
+ payload: { error: 'Channel not found' },
133
+ };
134
+ }
135
+ return { status: 200, payload: result };
136
+ }
137
+ if (method === 'workplace.list') {
138
+ const result = await service_1.coreApiService.callListWorkplaces();
139
+ if (!result) {
140
+ return {
141
+ status: 500,
142
+ payload: { error: 'Core API service did not respond' },
143
+ };
144
+ }
145
+ return { status: 200, payload: result };
146
+ }
147
+ if (method === 'workplace.get') {
148
+ if (!params?.id || typeof params.id !== 'string') {
149
+ return { status: 400, payload: { error: 'id is required' } };
150
+ }
151
+ const result = await service_1.coreApiService.callGetWorkplace(params.id);
152
+ if (!result) {
153
+ return {
154
+ status: 404,
155
+ payload: { error: 'Workplace not found' },
156
+ };
157
+ }
158
+ return { status: 200, payload: result };
159
+ }
160
+ if (method === 'sendMessage') {
161
+ if (!params?.message || !params?.communicationChannel) {
162
+ return { status: 400, payload: { error: 'message and communicationChannel are required' } };
163
+ }
164
+ const msg = params.message;
165
+ const channel = params.communicationChannel;
166
+ const result = await service_1.coreApiService.callSendMessage({
167
+ message: msg,
168
+ communicationChannel: channel,
169
+ });
170
+ if (!result) {
171
+ return {
172
+ status: 500,
173
+ payload: { error: 'Core API service did not respond' },
174
+ };
175
+ }
176
+ return { status: 200, payload: result };
177
+ }
178
+ return {
179
+ status: 400,
180
+ payload: { error: 'Unknown core method' },
181
+ };
182
+ }
183
+ function buildToolMetadata(registry) {
184
+ return Object.keys(registry).map((name) => ({
185
+ name,
186
+ description: `Function: ${name}`,
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ inputs: {
191
+ type: 'object',
192
+ description: 'Input parameters for the function',
193
+ },
194
+ },
195
+ required: ['inputs'],
196
+ },
197
+ }));
198
+ }
199
+ function createRequestState(maxRequests, ttlExtendSeconds, runtimeLabel, toolNames) {
200
+ let requestCount = 0;
201
+ let lastRequestTime = Date.now();
202
+ return {
203
+ incrementRequestCount() {
204
+ requestCount += 1;
205
+ lastRequestTime = Date.now();
206
+ },
207
+ shouldShutdown() {
208
+ return maxRequests !== null && requestCount >= maxRequests;
209
+ },
210
+ getHealthStatus() {
211
+ return {
212
+ status: 'running',
213
+ requests: requestCount,
214
+ maxRequests,
215
+ requestsRemaining: maxRequests !== null ? Math.max(0, maxRequests - requestCount) : null,
216
+ lastRequestTime,
217
+ ttlExtendSeconds,
218
+ runtime: runtimeLabel,
219
+ tools: [...toolNames],
220
+ };
221
+ },
222
+ };
223
+ }
224
+ function createCallToolHandler(registry, state, onMaxRequests) {
225
+ return async function callTool(nameRaw, argsRaw) {
226
+ const toolName = String(nameRaw);
227
+ const fn = registry[toolName];
228
+ if (!fn) {
229
+ throw new Error(`Tool "${toolName}" not found in registry`);
230
+ }
231
+ if (typeof fn !== 'function') {
232
+ throw new Error(`Registry entry "${toolName}" is not a function`);
233
+ }
234
+ const args = (argsRaw ?? {});
235
+ const estimateMode = args.estimate === true;
236
+ if (!estimateMode) {
237
+ state.incrementRequestCount();
238
+ if (state.shouldShutdown()) {
239
+ onMaxRequests?.();
240
+ }
241
+ }
242
+ const requestEnv = args.env ?? {};
243
+ const originalEnv = { ...process.env };
244
+ Object.assign(process.env, requestEnv);
245
+ try {
246
+ const inputs = args.inputs ?? {};
247
+ const functionResult = await fn({
248
+ input: inputs,
249
+ context: {
250
+ env: process.env,
251
+ mode: estimateMode ? 'estimate' : 'execute',
252
+ },
253
+ });
254
+ const billing = normalizeBilling(functionResult.billing);
255
+ return {
256
+ content: [
257
+ {
258
+ type: 'text',
259
+ text: JSON.stringify(functionResult.output),
260
+ },
261
+ ],
262
+ billing,
263
+ };
264
+ }
265
+ catch (error) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: JSON.stringify({
271
+ error: error instanceof Error ? error.message : String(error ?? ''),
272
+ }),
273
+ },
274
+ ],
275
+ billing: { credits: 0 },
276
+ isError: true,
277
+ };
278
+ }
279
+ finally {
280
+ process.env = originalEnv;
281
+ }
282
+ };
283
+ }
284
+ function parseJSONBody(req) {
285
+ return new Promise((resolve, reject) => {
286
+ let body = '';
287
+ req.on('data', (chunk) => {
288
+ body += chunk.toString();
289
+ });
290
+ req.on('end', () => {
291
+ try {
292
+ resolve(body ? JSON.parse(body) : {});
293
+ }
294
+ catch (err) {
295
+ reject(err);
296
+ }
297
+ });
298
+ req.on('error', reject);
299
+ });
300
+ }
301
+ function sendJSON(res, statusCode, data) {
302
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
303
+ res.end(JSON.stringify(data));
304
+ }
305
+ function getDefaultHeaders(options) {
306
+ return {
307
+ 'Content-Type': 'application/json',
308
+ 'Access-Control-Allow-Origin': options?.allowOrigin ?? '*',
309
+ 'Access-Control-Allow-Methods': options?.allowMethods ?? 'GET, POST, OPTIONS',
310
+ 'Access-Control-Allow-Headers': options?.allowHeaders ?? 'Content-Type',
311
+ };
312
+ }
313
+ function createResponse(statusCode, body, headers) {
314
+ return {
315
+ statusCode,
316
+ headers,
317
+ body: JSON.stringify(body),
318
+ };
319
+ }
320
+ function getListeningPort(config) {
321
+ const envPort = Number.parseInt(process.env.PORT ?? '', 10);
322
+ if (!Number.isNaN(envPort)) {
323
+ return envPort;
324
+ }
325
+ return config.defaultPort ?? 3000;
326
+ }
327
+ function createSkedyulServer(config, registry) {
328
+ mergeRuntimeEnv();
329
+ if (config.coreApi?.service) {
330
+ service_1.coreApiService.register(config.coreApi.service);
331
+ if (config.coreApi.webhookHandler) {
332
+ service_1.coreApiService.setWebhookHandler(config.coreApi.webhookHandler);
333
+ }
334
+ }
335
+ const tools = buildToolMetadata(registry);
336
+ const toolNames = tools.map((tool) => tool.name);
337
+ const runtimeLabel = config.computeLayer;
338
+ const maxRequests = config.maxRequests ??
339
+ parseNumberEnv(process.env.MCP_MAX_REQUESTS) ??
340
+ null;
341
+ const ttlExtendSeconds = config.ttlExtendSeconds ??
342
+ parseNumberEnv(process.env.MCP_TTL_EXTEND) ??
343
+ 3600;
344
+ const state = createRequestState(maxRequests, ttlExtendSeconds, runtimeLabel, toolNames);
345
+ const server = new index_js_1.Server({
346
+ name: config.metadata.name,
347
+ version: config.metadata.version,
348
+ }, {
349
+ capabilities: {
350
+ tools: {},
351
+ },
352
+ });
353
+ const dedicatedShutdown = () => {
354
+ // eslint-disable-next-line no-console
355
+ console.log('Max requests reached, shutting down...');
356
+ setTimeout(() => process.exit(0), 1000);
357
+ };
358
+ const callTool = createCallToolHandler(registry, state, config.computeLayer === 'dedicated' ? dedicatedShutdown : undefined);
359
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
360
+ tools,
361
+ }));
362
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => callTool(request.params.name, request.params.arguments));
363
+ if (config.computeLayer === 'dedicated') {
364
+ return createDedicatedServerInstance(config, tools, callTool, state);
365
+ }
366
+ return createServerlessInstance(config, tools, callTool, state);
367
+ }
368
+ function createDedicatedServerInstance(config, tools, callTool, state) {
369
+ const port = getListeningPort(config);
370
+ const httpServer = http_1.default.createServer(async (req, res) => {
371
+ function sendCoreResult(result) {
372
+ sendJSON(res, result.status, result.payload);
373
+ }
374
+ try {
375
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
376
+ const pathname = url.pathname;
377
+ if (pathname === '/health' && req.method === 'GET') {
378
+ sendJSON(res, 200, state.getHealthStatus());
379
+ return;
380
+ }
381
+ if (pathname === '/estimate' && req.method === 'POST') {
382
+ let estimateBody;
383
+ try {
384
+ estimateBody = (await parseJSONBody(req));
385
+ }
386
+ catch {
387
+ sendJSON(res, 400, {
388
+ error: {
389
+ code: -32700,
390
+ message: 'Parse error',
391
+ },
392
+ });
393
+ return;
394
+ }
395
+ try {
396
+ const estimateResponse = await callTool(estimateBody.name, {
397
+ inputs: estimateBody.inputs,
398
+ estimate: true,
399
+ });
400
+ sendJSON(res, 200, {
401
+ billing: estimateResponse.billing ?? { credits: 0 },
402
+ });
403
+ }
404
+ catch (err) {
405
+ sendJSON(res, 500, {
406
+ error: {
407
+ code: -32603,
408
+ message: err instanceof Error ? err.message : String(err ?? ''),
409
+ },
410
+ });
411
+ }
412
+ return;
413
+ }
414
+ if (pathname === '/core' && req.method === 'POST') {
415
+ let coreBody;
416
+ try {
417
+ coreBody = (await parseJSONBody(req));
418
+ }
419
+ catch {
420
+ sendJSON(res, 400, {
421
+ error: {
422
+ code: -32700,
423
+ message: 'Parse error',
424
+ },
425
+ });
426
+ return;
427
+ }
428
+ if (!coreBody?.method) {
429
+ sendJSON(res, 400, {
430
+ error: {
431
+ code: -32602,
432
+ message: 'Missing method',
433
+ },
434
+ });
435
+ return;
436
+ }
437
+ const method = coreBody.method;
438
+ const result = await handleCoreMethod(method, coreBody.params);
439
+ sendCoreResult(result);
440
+ return;
441
+ }
442
+ if (pathname === '/core/webhook' && req.method === 'POST') {
443
+ let webhookBody = undefined;
444
+ try {
445
+ webhookBody = (await parseJSONBody(req));
446
+ }
447
+ catch {
448
+ sendJSON(res, 400, { status: 'parse-error' });
449
+ return;
450
+ }
451
+ const webhookRequest = {
452
+ method: req.method,
453
+ headers: Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
454
+ key,
455
+ typeof value === 'string' ? value : value?.[0] ?? '',
456
+ ])),
457
+ body: webhookBody,
458
+ query: Object.fromEntries(url.searchParams.entries()),
459
+ };
460
+ const webhookResponse = await service_1.coreApiService.dispatchWebhook(webhookRequest);
461
+ res.writeHead(webhookResponse.status, {
462
+ 'Content-Type': 'application/json',
463
+ });
464
+ res.end(JSON.stringify(webhookResponse.body ?? {}));
465
+ return;
466
+ }
467
+ if (pathname === '/mcp' && req.method === 'POST') {
468
+ let body;
469
+ try {
470
+ body = (await parseJSONBody(req));
471
+ }
472
+ catch {
473
+ sendJSON(res, 400, {
474
+ jsonrpc: '2.0',
475
+ id: null,
476
+ error: {
477
+ code: -32700,
478
+ message: 'Parse error',
479
+ },
480
+ });
481
+ return;
482
+ }
483
+ try {
484
+ const { jsonrpc, id, method, params } = body;
485
+ if (jsonrpc !== '2.0') {
486
+ sendJSON(res, 400, {
487
+ jsonrpc: '2.0',
488
+ id,
489
+ error: {
490
+ code: -32600,
491
+ message: 'Invalid Request',
492
+ },
493
+ });
494
+ return;
495
+ }
496
+ let result;
497
+ if (method === 'tools/list') {
498
+ result = { tools };
499
+ }
500
+ else if (method === 'tools/call') {
501
+ result = await callTool(params?.name, params?.arguments);
502
+ }
503
+ else {
504
+ sendJSON(res, 200, {
505
+ jsonrpc: '2.0',
506
+ id,
507
+ error: {
508
+ code: -32601,
509
+ message: `Method not found: ${method}`,
510
+ },
511
+ });
512
+ return;
513
+ }
514
+ sendJSON(res, 200, {
515
+ jsonrpc: '2.0',
516
+ id,
517
+ result,
518
+ });
519
+ }
520
+ catch (err) {
521
+ sendJSON(res, 500, {
522
+ jsonrpc: '2.0',
523
+ id: body?.id ?? null,
524
+ error: {
525
+ code: -32603,
526
+ message: err instanceof Error ? err.message : String(err ?? ''),
527
+ },
528
+ });
529
+ }
530
+ return;
531
+ }
532
+ sendJSON(res, 404, {
533
+ jsonrpc: '2.0',
534
+ id: null,
535
+ error: {
536
+ code: -32601,
537
+ message: 'Not Found',
538
+ },
539
+ });
540
+ }
541
+ catch (err) {
542
+ sendJSON(res, 500, {
543
+ jsonrpc: '2.0',
544
+ id: null,
545
+ error: {
546
+ code: -32603,
547
+ message: err instanceof Error ? err.message : String(err ?? ''),
548
+ },
549
+ });
550
+ }
551
+ });
552
+ return {
553
+ async listen(listenPort) {
554
+ const finalPort = listenPort ?? port;
555
+ return new Promise((resolve, reject) => {
556
+ httpServer.listen(finalPort, () => {
557
+ // eslint-disable-next-line no-console
558
+ console.log(`MCP Server running on port ${finalPort}`);
559
+ // eslint-disable-next-line no-console
560
+ console.log(`Registry loaded with ${tools.length} tools: ${tools
561
+ .map((tool) => tool.name)
562
+ .join(', ')}`);
563
+ resolve();
564
+ });
565
+ httpServer.once('error', reject);
566
+ });
567
+ },
568
+ getHealthStatus: () => state.getHealthStatus(),
569
+ };
570
+ }
571
+ function createServerlessInstance(config, tools, callTool, state) {
572
+ const headers = getDefaultHeaders(config.cors);
573
+ return {
574
+ async handler(event) {
575
+ try {
576
+ const path = event.path;
577
+ const method = event.httpMethod;
578
+ if (method === 'OPTIONS') {
579
+ return createResponse(200, { message: 'OK' }, headers);
580
+ }
581
+ if (path === '/core' && method === 'POST') {
582
+ let coreBody;
583
+ try {
584
+ coreBody = event.body ? JSON.parse(event.body) : {};
585
+ }
586
+ catch {
587
+ return createResponse(400, {
588
+ error: {
589
+ code: -32700,
590
+ message: 'Parse error',
591
+ },
592
+ }, headers);
593
+ }
594
+ if (!coreBody?.method) {
595
+ return createResponse(400, {
596
+ error: {
597
+ code: -32602,
598
+ message: 'Missing method',
599
+ },
600
+ }, headers);
601
+ }
602
+ const method = coreBody.method;
603
+ const result = await handleCoreMethod(method, coreBody.params);
604
+ return createResponse(result.status, result.payload, headers);
605
+ }
606
+ if (path === '/core/webhook' && method === 'POST') {
607
+ let webhookBody = undefined;
608
+ try {
609
+ webhookBody = event.body ? JSON.parse(event.body) : {};
610
+ }
611
+ catch {
612
+ return createResponse(400, { status: 'parse-error' }, headers);
613
+ }
614
+ const webhookRequest = {
615
+ method,
616
+ headers: event.headers ?? {},
617
+ body: webhookBody,
618
+ query: event.queryStringParameters ?? {},
619
+ };
620
+ const webhookResponse = await service_1.coreApiService.dispatchWebhook(webhookRequest);
621
+ return createResponse(webhookResponse.status, webhookResponse.body ?? {}, headers);
622
+ }
623
+ if (path === '/estimate' && method === 'POST') {
624
+ let estimateBody;
625
+ try {
626
+ estimateBody = event.body ? JSON.parse(event.body) : {};
627
+ }
628
+ catch {
629
+ return createResponse(400, {
630
+ error: {
631
+ code: -32700,
632
+ message: 'Parse error',
633
+ },
634
+ }, headers);
635
+ }
636
+ try {
637
+ const estimateResponse = await callTool(estimateBody.name, {
638
+ inputs: estimateBody.inputs,
639
+ estimate: true,
640
+ });
641
+ return createResponse(200, {
642
+ billing: estimateResponse.billing ?? { credits: 0 },
643
+ }, headers);
644
+ }
645
+ catch (err) {
646
+ return createResponse(500, {
647
+ error: {
648
+ code: -32603,
649
+ message: err instanceof Error ? err.message : String(err ?? ''),
650
+ },
651
+ }, headers);
652
+ }
653
+ }
654
+ if (path === '/health' && method === 'GET') {
655
+ return createResponse(200, state.getHealthStatus(), headers);
656
+ }
657
+ if (path === '/mcp' && method === 'POST') {
658
+ let body;
659
+ try {
660
+ body = event.body ? JSON.parse(event.body) : {};
661
+ }
662
+ catch {
663
+ return createResponse(400, {
664
+ jsonrpc: '2.0',
665
+ id: null,
666
+ error: {
667
+ code: -32700,
668
+ message: 'Parse error',
669
+ },
670
+ }, headers);
671
+ }
672
+ try {
673
+ const { jsonrpc, id, method: rpcMethod, params } = body;
674
+ if (jsonrpc !== '2.0') {
675
+ return createResponse(400, {
676
+ jsonrpc: '2.0',
677
+ id,
678
+ error: {
679
+ code: -32600,
680
+ message: 'Invalid Request',
681
+ },
682
+ }, headers);
683
+ }
684
+ let result;
685
+ if (rpcMethod === 'tools/list') {
686
+ result = { tools };
687
+ }
688
+ else if (rpcMethod === 'tools/call') {
689
+ result = await callTool(params?.name, params?.arguments);
690
+ }
691
+ else {
692
+ return createResponse(200, {
693
+ jsonrpc: '2.0',
694
+ id,
695
+ error: {
696
+ code: -32601,
697
+ message: `Method not found: ${rpcMethod}`,
698
+ },
699
+ }, headers);
700
+ }
701
+ return createResponse(200, {
702
+ jsonrpc: '2.0',
703
+ id,
704
+ result,
705
+ }, headers);
706
+ }
707
+ catch (err) {
708
+ return createResponse(500, {
709
+ jsonrpc: '2.0',
710
+ id: body?.id ?? null,
711
+ error: {
712
+ code: -32603,
713
+ message: err instanceof Error ? err.message : String(err ?? ''),
714
+ },
715
+ }, headers);
716
+ }
717
+ }
718
+ return createResponse(404, {
719
+ jsonrpc: '2.0',
720
+ id: null,
721
+ error: {
722
+ code: -32601,
723
+ message: 'Not Found',
724
+ },
725
+ }, headers);
726
+ }
727
+ catch (err) {
728
+ return createResponse(500, {
729
+ jsonrpc: '2.0',
730
+ id: null,
731
+ error: {
732
+ code: -32603,
733
+ message: err instanceof Error ? err.message : String(err ?? ''),
734
+ },
735
+ }, headers);
736
+ }
737
+ },
738
+ getHealthStatus: () => state.getHealthStatus(),
739
+ };
740
+ }
741
+ exports.server = {
742
+ create: createSkedyulServer,
743
+ };
@@ -0,0 +1,94 @@
1
+ import type { CoreApiConfig } from './core/types';
2
+ export interface ToolContext {
3
+ env: Record<string, string | undefined>;
4
+ mode?: 'execute' | 'estimate';
5
+ }
6
+ export interface ToolParams<Input, Output> {
7
+ input: Input;
8
+ context: ToolContext;
9
+ }
10
+ export interface BillingInfo {
11
+ credits: number;
12
+ }
13
+ export interface ToolExecutionResult<Output = unknown> {
14
+ output: Output;
15
+ billing: BillingInfo;
16
+ }
17
+ export type ToolHandler<Input, Output> = (params: ToolParams<Input, Output>) => Promise<ToolExecutionResult<Output>> | ToolExecutionResult<Output>;
18
+ export type ToolRegistry = Record<string, ToolHandler<unknown, unknown>>;
19
+ export type ToolName<T extends ToolRegistry> = Extract<keyof T, string>;
20
+ export interface ToolMetadata {
21
+ name: string;
22
+ description: string;
23
+ inputSchema: {
24
+ type: 'object';
25
+ properties: {
26
+ inputs: {
27
+ type: 'object';
28
+ description: string;
29
+ };
30
+ };
31
+ required: ['inputs'];
32
+ };
33
+ }
34
+ export interface HealthStatus {
35
+ status: 'running';
36
+ requests: number;
37
+ maxRequests: number | null;
38
+ requestsRemaining: number | null;
39
+ lastRequestTime: number;
40
+ ttlExtendSeconds: number;
41
+ runtime: string;
42
+ tools: string[];
43
+ }
44
+ export type ComputeLayer = 'dedicated' | 'serverless';
45
+ export interface ServerMetadata {
46
+ name: string;
47
+ version: string;
48
+ }
49
+ export interface CorsOptions {
50
+ allowOrigin?: string;
51
+ allowMethods?: string;
52
+ allowHeaders?: string;
53
+ }
54
+ export interface SkedyulServerConfig {
55
+ computeLayer: ComputeLayer;
56
+ metadata: ServerMetadata;
57
+ defaultPort?: number;
58
+ maxRequests?: number | null;
59
+ ttlExtendSeconds?: number;
60
+ cors?: CorsOptions;
61
+ coreApi?: CoreApiConfig;
62
+ }
63
+ export interface APIGatewayProxyEvent {
64
+ body: string | null;
65
+ headers: Record<string, string>;
66
+ httpMethod: string;
67
+ path: string;
68
+ queryStringParameters: Record<string, string> | null;
69
+ requestContext: {
70
+ requestId: string;
71
+ };
72
+ }
73
+ export interface APIGatewayProxyResult {
74
+ statusCode: number;
75
+ headers?: Record<string, string>;
76
+ body: string;
77
+ }
78
+ export interface ToolCallResponse {
79
+ content: {
80
+ type: 'text';
81
+ text: string;
82
+ }[];
83
+ billing?: BillingInfo;
84
+ isError?: boolean;
85
+ }
86
+ export interface DedicatedServerInstance {
87
+ listen(port?: number): Promise<void>;
88
+ getHealthStatus(): HealthStatus;
89
+ }
90
+ export interface ServerlessServerInstance {
91
+ handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
92
+ getHealthStatus(): HealthStatus;
93
+ }
94
+ export type SkedyulServerInstance = DedicatedServerInstance | ServerlessServerInstance;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "skedyul",
3
+ "version": "0.1.0",
4
+ "description": "The Skedyul SDK for Node.js",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": ["dist"],
14
+ "scripts": {
15
+ "build": "tsc --build tsconfig.json",
16
+ "test": "npm run build && node --test tests/server.test.js"
17
+ },
18
+ "keywords": ["mcp", "skedyul", "serverless", "node", "typescript"],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/skedyul/skedyul-node"
22
+ },
23
+ "author": "Skedyul <support@skedyul.com>",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.10.1",
30
+ "typescript": "^5.5.0"
31
+ }
32
+ }
33
+