@cyanheads/mcp-ts-core 0.1.15 → 0.1.16
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/CLAUDE.md +36 -195
- package/README.md +2 -2
- package/package.json +12 -14
- package/scripts/lint-mcp.ts +167 -0
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agent Protocol
|
|
2
2
|
|
|
3
|
-
**Package:** `@cyanheads/mcp-ts-core` · **Version:** 0.1.
|
|
3
|
+
**Package:** `@cyanheads/mcp-ts-core` · **Version:** 0.1.16
|
|
4
4
|
**npm:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) · **Docker:** [ghcr.io/cyanheads/mcp-ts-core](https://ghcr.io/cyanheads/mcp-ts-core)
|
|
5
5
|
|
|
6
6
|
> **Developer note:** Never assume. Read related files and docs before making changes. Read full file content for context. Never edit a file before reading it.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
- **Unified Context.** Handlers receive `ctx` with logging (`ctx.log`), tenant-scoped storage (`ctx.state`), optional protocol capabilities (`ctx.elicit`, `ctx.sample`), and cancellation (`ctx.signal`).
|
|
15
15
|
- **Decoupled storage.** `ctx.state` for tenant-scoped KV. Never access persistence backends directly.
|
|
16
16
|
- **Runtime parity.** All features work with `stdio`/`http` and Worker bundle. Guard non-portable deps via `runtimeCaps`. Prefer runtime-agnostic abstractions (Hono + `@hono/mcp`, Fetch APIs).
|
|
17
|
+
- **Startup validation.** `createApp()` runs the definition linter before proceeding — errors (spec violations) throw `ConfigurationError` and block startup; warnings are logged. Also available standalone via `bun run lint:mcp` and as a devcheck step.
|
|
17
18
|
- **Elicitation for missing input.** Use `ctx.elicit` when the client supports it.
|
|
18
19
|
|
|
19
20
|
---
|
|
@@ -33,12 +34,12 @@
|
|
|
33
34
|
| `/auth` | `checkScopes` | Dynamic scope checking |
|
|
34
35
|
| `/storage` | `StorageService` | Storage abstraction |
|
|
35
36
|
| `/storage/types` | `IStorageProvider` | Provider interface |
|
|
36
|
-
| `/utils` | formatting, encoding, network, pagination, logging, runtime, telemetry, token counting, parsers†, sanitization†, scheduling† | All utilities (†optional peer deps
|
|
37
|
+
| `/utils` | formatting, encoding, network, pagination, logging, runtime, telemetry, token counting, parsers†, sanitization†, scheduling† | All utilities (†optional peer deps) |
|
|
37
38
|
| `/services` | `OpenRouterProvider`, `SpeechService`, `createSpeechProvider`, `ElevenLabsProvider`, `WhisperProvider`, `GraphService`, provider interfaces and types | LLM, Speech (TTS/STT), Graph services |
|
|
38
39
|
| `/linter` | `validateDefinitions`, `LintReport`, `LintDiagnostic`, `LintInput`, `LintSeverity` | Definition validation |
|
|
39
40
|
| `/testing` | `createMockContext` | Test helpers |
|
|
40
41
|
|
|
41
|
-
All subpaths prefixed with `@cyanheads/mcp-ts-core`. **†Tier 3 modules** require optional peer dependencies —
|
|
42
|
+
All subpaths prefixed with `@cyanheads/mcp-ts-core`. **†Tier 3 modules** require optional peer dependencies — see `package.json` `peerDependencies`. Tier 3 methods that lazy-load deps are **async**.
|
|
42
43
|
|
|
43
44
|
### Import conventions
|
|
44
45
|
|
|
@@ -93,22 +94,13 @@ export default createWorkerHandler({
|
|
|
93
94
|
});
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
Per-request `McpServer` factory (security: SDK GHSA-345p-7cg4-v4c7). Requires `compatibility_flags = ["nodejs_compat"]` and `compatibility_date >= "2025-09-01"` in `wrangler.toml`. Only `in-memory`, `cloudflare-r2`, `cloudflare-kv`, `cloudflare-d1` storage in Workers. See `api-workers` skill for full details.
|
|
97
98
|
|
|
98
99
|
### Interfaces
|
|
99
100
|
|
|
100
101
|
`createApp()` returns `Promise<ServerHandle>`. `createWorkerHandler()` returns an `ExportedHandler`.
|
|
101
102
|
|
|
102
103
|
```ts
|
|
103
|
-
interface CreateAppOptions {
|
|
104
|
-
name?: string;
|
|
105
|
-
version?: string;
|
|
106
|
-
tools?: AnyToolDefinition[];
|
|
107
|
-
resources?: AnyResourceDefinition[];
|
|
108
|
-
prompts?: PromptDefinition[];
|
|
109
|
-
setup?: (core: CoreServices) => void | Promise<void>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
104
|
interface CoreServices {
|
|
113
105
|
config: AppConfig;
|
|
114
106
|
logger: Logger;
|
|
@@ -181,36 +173,7 @@ export const myTool = tool('my_tool', {
|
|
|
181
173
|
|
|
182
174
|
**`format`**: Maps output to `ContentBlock[]`. Omit for JSON stringify default. Additional formatters: `markdown()` (builder), `diffFormatter` (async), `tableFormatter`, `treeFormatter` from `/utils`.
|
|
183
175
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
Add `task: true` for long-running async operations. The framework manages the full lifecycle.
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
const asyncCountdown = tool('async_countdown', {
|
|
190
|
-
description: 'Count down with progress updates.',
|
|
191
|
-
task: true,
|
|
192
|
-
input: z.object({
|
|
193
|
-
count: z.number().int().positive().describe('Count down from'),
|
|
194
|
-
delayMs: z.number().default(1000).describe('Delay between counts in ms'),
|
|
195
|
-
}),
|
|
196
|
-
output: z.object({
|
|
197
|
-
finalCount: z.number().describe('Final count value'),
|
|
198
|
-
message: z.string().describe('Completion message'),
|
|
199
|
-
}),
|
|
200
|
-
async handler(input, ctx) {
|
|
201
|
-
await ctx.progress!.setTotal(input.count);
|
|
202
|
-
for (let i = input.count; i > 0; i--) {
|
|
203
|
-
if (ctx.signal.aborted) break;
|
|
204
|
-
await ctx.progress!.update(`Counting: ${i}`);
|
|
205
|
-
await new Promise(resolve => setTimeout(resolve, input.delayMs));
|
|
206
|
-
await ctx.progress!.increment();
|
|
207
|
-
}
|
|
208
|
-
return { finalCount: 0, message: 'Countdown complete' };
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
With `task: true`: creates task → returns task ID immediately → runs handler in background with `ctx.progress` → returns status on poll → stores result/error → signals `ctx.signal` on cancellation. **Escape hatch:** `TaskToolDefinition` from `/tasks` for custom lifecycle.
|
|
176
|
+
**Task tools:** Add `task: true` for long-running async operations. Framework manages lifecycle: creates task → returns ID immediately → runs handler in background with `ctx.progress` → stores result/error → `ctx.signal` for cancellation. See `add-tool` skill for full example.
|
|
214
177
|
|
|
215
178
|
---
|
|
216
179
|
|
|
@@ -233,7 +196,7 @@ export const myResource = resource('myscheme://{itemId}/data', {
|
|
|
233
196
|
});
|
|
234
197
|
```
|
|
235
198
|
|
|
236
|
-
Handler receives `(params, ctx)` — URI on `ctx.uri` if needed. Large lists must use `extractCursor`/`paginateArray` from `/utils`.
|
|
199
|
+
Handler receives `(params, ctx)` — URI on `ctx.uri` if needed. Large lists must use `extractCursor`/`paginateArray` from `/utils`.
|
|
237
200
|
|
|
238
201
|
---
|
|
239
202
|
|
|
@@ -263,11 +226,6 @@ Prompts are pure message templates — no `Context`, no auth, no side effects.
|
|
|
263
226
|
Init/accessor pattern — initialized in `setup()`, accessed at request time.
|
|
264
227
|
|
|
265
228
|
```ts
|
|
266
|
-
// src/services/my-domain/my-service.ts
|
|
267
|
-
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
|
|
268
|
-
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
|
|
269
|
-
import type { Context } from '@cyanheads/mcp-ts-core';
|
|
270
|
-
|
|
271
229
|
export class MyService {
|
|
272
230
|
constructor(private readonly config: AppConfig, private readonly storage: StorageService) {}
|
|
273
231
|
async doWork(input: string, ctx: Context): Promise<string> {
|
|
@@ -286,7 +244,7 @@ export function getMyService(): MyService {
|
|
|
286
244
|
}
|
|
287
245
|
```
|
|
288
246
|
|
|
289
|
-
Usage: `getMyService().doWork(input.query, ctx)`.
|
|
247
|
+
Usage: `getMyService().doWork(input.query, ctx)`. Convention: `ctx.elicit`/`ctx.sample` only from tool handlers, not services.
|
|
290
248
|
|
|
291
249
|
---
|
|
292
250
|
|
|
@@ -312,36 +270,23 @@ interface Context {
|
|
|
312
270
|
|
|
313
271
|
### `ctx.log`
|
|
314
272
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
**`ctx.log` is opt-in, for domain-specific logging.** Use it when the handler does meaningful work worth tracing beyond the automatic metrics — external API calls, multi-step processing, business-significant events. Trivial handlers (echo, passthrough) don't need it.
|
|
318
|
-
|
|
319
|
-
Methods: `debug`, `info`, `notice`, `warning`, `error`. All calls auto-include `requestId`, `traceId`, `tenantId`, `spanId`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
|
|
273
|
+
Opt-in domain-specific logging. Methods: `debug`, `info`, `notice`, `warning`, `error`. Auto-includes `requestId`, `traceId`, `tenantId`, `spanId`. Use `ctx.log` in handlers; global `logger` for startup/shutdown/background.
|
|
320
274
|
|
|
321
275
|
### `ctx.state`
|
|
322
276
|
|
|
323
|
-
Tenant-scoped KV
|
|
277
|
+
Tenant-scoped KV. Accepts any serializable value — no manual `JSON.stringify`/`JSON.parse` needed.
|
|
324
278
|
|
|
325
279
|
```ts
|
|
326
|
-
|
|
327
|
-
await ctx.state.set('item:123', { name: 'Widget', count: 42 }); // any serializable value
|
|
280
|
+
await ctx.state.set('item:123', { name: 'Widget', count: 42 });
|
|
328
281
|
await ctx.state.set('item:123', data, { ttl: 3600 }); // with TTL (seconds)
|
|
329
282
|
const item = await ctx.state.get<Item>('item:123'); // T | null
|
|
330
283
|
const safe = await ctx.state.get('item:123', ItemSchema); // Zod-validated T | null
|
|
331
284
|
await ctx.state.delete('item:123');
|
|
332
|
-
|
|
333
|
-
// Batch operations
|
|
334
285
|
const values = await ctx.state.getMany<Item>(['item:1', 'item:2']); // Map<string, T>
|
|
335
|
-
await ctx.state.setMany(new Map([['a', 1], ['b', 2]])); // void
|
|
336
|
-
const deleted = await ctx.state.deleteMany(['item:1', 'item:2']); // number
|
|
337
|
-
|
|
338
|
-
// Pagination
|
|
339
286
|
const page = await ctx.state.list('item:', { cursor, limit: 20 }); // { items, cursor? }
|
|
340
287
|
```
|
|
341
288
|
|
|
342
|
-
Throws `McpError(InvalidRequest)` if `tenantId` missing.
|
|
343
|
-
|
|
344
|
-
**Tenant ID** comes from JWT `'tid'` claim (HTTP) or `'default'` (stdio). Validation: max 128 chars, alphanumeric/hyphens/underscores/dots, start/end alphanumeric, no `../`, no consecutive dots.
|
|
289
|
+
Throws `McpError(InvalidRequest)` if `tenantId` missing. Tenant ID from JWT `'tid'` claim (HTTP) or `'default'` (stdio).
|
|
345
290
|
|
|
346
291
|
### `ctx.elicit` / `ctx.sample`
|
|
347
292
|
|
|
@@ -354,119 +299,43 @@ if (ctx.elicit) {
|
|
|
354
299
|
}));
|
|
355
300
|
if (result.action === 'accept') useFormat(result.data.format);
|
|
356
301
|
}
|
|
357
|
-
if (ctx.sample) {
|
|
358
|
-
const result = await ctx.sample([
|
|
359
|
-
{ role: 'user', content: { type: 'text', text: `Summarize: ${data}` } },
|
|
360
|
-
], { maxTokens: 500 });
|
|
361
|
-
}
|
|
362
302
|
```
|
|
363
303
|
|
|
364
304
|
### `ctx.progress`
|
|
365
305
|
|
|
366
306
|
Present when `task: true`. Methods: `setTotal(n)`, `increment(amount?)`, `update(message)`.
|
|
367
307
|
|
|
308
|
+
See `api-context` skill for full details.
|
|
309
|
+
|
|
368
310
|
---
|
|
369
311
|
|
|
370
312
|
## Error Handling
|
|
371
313
|
|
|
372
314
|
**Default: just throw.** The framework catches all errors from handlers, classifies them by type/message, and returns `isError: true` with an appropriate JSON-RPC error code. Plain `Error`, `ZodError`, and any other thrown value are handled automatically.
|
|
373
315
|
|
|
374
|
-
|
|
375
|
-
// Simple — framework classifies automatically
|
|
376
|
-
throw new Error('Thing not found');
|
|
377
|
-
|
|
378
|
-
// Zod .parse() failures are caught and mapped to ValidationError
|
|
379
|
-
const data = MySchema.parse(rawData);
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
**Auto-classification:** The framework maps plain `Error` messages to JSON-RPC codes via pattern matching. Resolution order: `McpError` code (preserved as-is) → JS constructor name (`TypeError` → `ValidationError`) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name → `InternalError` fallback.
|
|
383
|
-
|
|
384
|
-
Common message patterns match these keywords (first match wins):
|
|
385
|
-
|
|
386
|
-
| Pattern | Code | Example messages |
|
|
387
|
-
|:--------|:-----|:-----------------|
|
|
388
|
-
| `unauthorized`, `unauthenticated`, `not authorized`, `invalid[_\s-]token`, `expired[_\s-]token` | Unauthorized | "unauthorized access", "invalid_token" |
|
|
389
|
-
| `permission`, `forbidden`, `access denied`, `not allowed` | Forbidden | "permission denied" |
|
|
390
|
-
| `not found`, `no such`, `doesn't exist`, `couldn't find` | NotFound | "resource not found" |
|
|
391
|
-
| `invalid`, `validation`, `malformed`, `bad request`, `wrong format`, `missing required/param/field/…` | ValidationError | "invalid input", "missing required field", "wrong format" |
|
|
392
|
-
| `conflict`, `already exists`, `duplicate`, `unique constraint` | Conflict | "already exists", "unique constraint" |
|
|
393
|
-
| `rate limit`, `too many requests`, `throttled` | RateLimited | "rate limit exceeded" |
|
|
394
|
-
| `timeout`, `timed out`, `deadline exceeded` | Timeout | "request timed out" |
|
|
395
|
-
| `abort`, `aborted`, `cancelled`, `canceled` | Timeout | "request aborted", "operation cancelled" |
|
|
396
|
-
| `service unavailable`, `bad gateway`, `gateway timeout`, `upstream error` | ServiceUnavailable | "service unavailable" |
|
|
397
|
-
| `zod`, `zoderror`, `schema validation` | ValidationError | "ZodError", "schema validation failed" |
|
|
316
|
+
**Auto-classification:** Resolution order: `McpError` code (preserved as-is) → JS constructor name (`TypeError` → `ValidationError`) → provider patterns (HTTP status codes, AWS errors, DB errors) → common message patterns → `AbortError` name → `InternalError` fallback.
|
|
398
317
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
**Error factories (preferred):** Shorter than `new McpError(...)` and self-documenting. Available from `@cyanheads/mcp-ts-core/errors`:
|
|
318
|
+
**When you need a specific code**, use error factories (preferred) or `McpError`:
|
|
402
319
|
|
|
403
320
|
```ts
|
|
404
|
-
import { notFound, validationError
|
|
405
|
-
|
|
321
|
+
import { notFound, validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
406
322
|
throw notFound('Item not found', { itemId: '123' });
|
|
407
323
|
throw validationError('Missing required field: name', { field: 'name' });
|
|
408
|
-
|
|
409
|
-
// With cause for error chaining
|
|
410
|
-
throw serviceUnavailable('API call failed', { url }, { cause: error });
|
|
411
324
|
```
|
|
412
325
|
|
|
413
|
-
Available factories: `invalidParams`, `invalidRequest`, `notFound`, `forbidden`, `unauthorized`, `validationError`, `conflict`, `rateLimited`, `timeout`, `serviceUnavailable`, `configurationError`. All accept `(message, data?, options?)` where `options` is `{ cause?: unknown }`.
|
|
326
|
+
Available factories: `invalidParams`, `invalidRequest`, `notFound`, `forbidden`, `unauthorized`, `validationError`, `conflict`, `rateLimited`, `timeout`, `serviceUnavailable`, `configurationError`. All accept `(message, data?, options?)` where `options` is `{ cause?: unknown }`.
|
|
414
327
|
|
|
415
|
-
|
|
328
|
+
For codes not covered by factories, use `new McpError(JsonRpcErrorCode.DatabaseError, message, data)`.
|
|
416
329
|
|
|
417
|
-
|
|
418
|
-
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
419
|
-
|
|
420
|
-
throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection pool exhausted', {
|
|
421
|
-
pool: 'primary',
|
|
422
|
-
});
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
| Code | Value | When to Use |
|
|
426
|
-
|:-----|------:|:------------|
|
|
427
|
-
| `InvalidParams` | -32602 | Bad input, missing fields, schema validation |
|
|
428
|
-
| `InvalidRequest` | -32600 | Unsupported operation, missing client capability |
|
|
429
|
-
| `NotFound` | -32001 | Resource/entity doesn't exist |
|
|
430
|
-
| `Forbidden` | -32005 | Authenticated but insufficient scopes |
|
|
431
|
-
| `Unauthorized` | -32006 | No auth, invalid/expired token |
|
|
432
|
-
| `RateLimited` | -32003 | Rate limit exceeded |
|
|
433
|
-
| `ServiceUnavailable` | -32000 | External dependency down |
|
|
434
|
-
| `Timeout` | -32004 | Operation exceeded time limit |
|
|
435
|
-
| `ConfigurationError` | -32008 | Missing env var, invalid config |
|
|
436
|
-
| `ValidationError` | -32007 | Business rule violation (not schema) |
|
|
437
|
-
| `Conflict` | -32002 | Duplicate key, version mismatch |
|
|
438
|
-
| `InitializationFailed` | -32009 | Startup failure |
|
|
439
|
-
| `DatabaseError` | -32010 | Storage layer failure |
|
|
440
|
-
| `SerializationError` | -32070 | Data serialization/deserialization failed |
|
|
441
|
-
| `InternalError` | -32603 | Catch-all for programmer errors |
|
|
442
|
-
| `UnknownError` | -32099 | Generic fallback (rare) |
|
|
443
|
-
|
|
444
|
-
**Where handled:** Handlers throw (no try/catch) → handler factory catches, classifies (`ZodError` → `ValidationError`, message pattern matching for common cases, `McpError` preserved as-is), normalizes to `isError: true` → services use `ErrorHandler.tryCatch` for recovery.
|
|
330
|
+
See `api-errors` skill for the full pattern-matching table, error code reference, and detailed examples.
|
|
445
331
|
|
|
446
332
|
---
|
|
447
333
|
|
|
448
334
|
## Auth
|
|
449
335
|
|
|
450
|
-
Inline `auth` on definitions (primary pattern):
|
|
451
|
-
|
|
452
|
-
```ts
|
|
453
|
-
const myTool = tool('my_tool', {
|
|
454
|
-
input: z.object({ query: z.string().describe('Search query') }),
|
|
455
|
-
output: z.object({ result: z.string().describe('Search result') }),
|
|
456
|
-
auth: ['tool:my_tool:read'],
|
|
457
|
-
async handler(input, ctx) { ... },
|
|
458
|
-
});
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
Handler factory checks auth scopes before calling handler. Dynamic scopes via `/auth`:
|
|
336
|
+
Inline `auth` on definitions (primary pattern): `auth: ['tool:my_tool:read']`. Handler factory checks scopes before calling handler. Dynamic scopes via `checkScopes(ctx, [...])` from `/auth`.
|
|
462
337
|
|
|
463
|
-
|
|
464
|
-
import { checkScopes } from '@cyanheads/mcp-ts-core/auth';
|
|
465
|
-
|
|
466
|
-
checkScopes(ctx, [`team:${input.teamId}:write`]);
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
**Modes** (`MCP_AUTH_MODE`): `none` (default) | `jwt` (local secret via `MCP_AUTH_SECRET_KEY`) | `oauth` (JWKS via `OAUTH_ISSUER_URL`, `OAUTH_AUDIENCE`). Claims: `clientId` (cid/client_id), `scopes` (scp/scope), `sub`, `tenantId` (tid). Unprotected endpoints: `/healthz`, `GET /mcp`. CORS: `MCP_ALLOWED_ORIGINS` or `*`. Stdio: no HTTP auth.
|
|
338
|
+
**Modes** (`MCP_AUTH_MODE`): `none` (default) | `jwt` (local secret via `MCP_AUTH_SECRET_KEY`) | `oauth` (JWKS via `OAUTH_ISSUER_URL`, `OAUTH_AUDIENCE`). See `api-auth` skill for claims, CORS, and detailed config.
|
|
470
339
|
|
|
471
340
|
---
|
|
472
341
|
|
|
@@ -486,28 +355,14 @@ Managed by `@cyanheads/mcp-ts-core`. Validated via Zod. Precedence: `createApp()
|
|
|
486
355
|
|
|
487
356
|
### Server config (separate schema)
|
|
488
357
|
|
|
489
|
-
Own Zod schema for domain-specific env vars. **Never merge with core's schema.** Lazy-parse — do not eagerly parse `process.env` at top-level (Workers inject env at request time via `injectEnvVars()`).
|
|
490
|
-
|
|
491
|
-
```ts
|
|
492
|
-
// src/config/server-config.ts
|
|
493
|
-
const ServerConfigSchema = z.object({
|
|
494
|
-
myApiKey: z.string().describe('External API key'),
|
|
495
|
-
maxResults: z.coerce.number().default(100),
|
|
496
|
-
});
|
|
497
|
-
type ServerConfig = z.infer<typeof ServerConfigSchema>;
|
|
498
|
-
let _config: ServerConfig | undefined;
|
|
499
|
-
export function getServerConfig(): ServerConfig {
|
|
500
|
-
_config ??= ServerConfigSchema.parse({ myApiKey: process.env.MY_API_KEY, maxResults: process.env.MY_MAX_RESULTS });
|
|
501
|
-
return _config;
|
|
502
|
-
}
|
|
503
|
-
```
|
|
358
|
+
Own Zod schema for domain-specific env vars. **Never merge with core's schema.** Lazy-parse — do not eagerly parse `process.env` at top-level (Workers inject env at request time via `injectEnvVars()`). See `api-config` skill for example.
|
|
504
359
|
|
|
505
360
|
---
|
|
506
361
|
|
|
507
362
|
## Testing
|
|
508
363
|
|
|
509
364
|
```ts
|
|
510
|
-
import { describe, expect, it
|
|
365
|
+
import { describe, expect, it } from 'vitest';
|
|
511
366
|
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
|
|
512
367
|
import { myTool } from '@/mcp-server/tools/definitions/my-tool.tool.js';
|
|
513
368
|
|
|
@@ -517,17 +372,12 @@ describe('myTool', () => {
|
|
|
517
372
|
const result = await myTool.handler(myTool.input.parse({ query: 'hello' }), ctx);
|
|
518
373
|
expect(result.result).toBe('Found: hello');
|
|
519
374
|
});
|
|
520
|
-
it('throws on invalid state', async () => {
|
|
521
|
-
await expect(myTool.handler(myTool.input.parse({ query: 'BAD' }), createMockContext())).rejects.toThrow();
|
|
522
|
-
});
|
|
523
375
|
});
|
|
524
376
|
```
|
|
525
377
|
|
|
526
378
|
**`createMockContext` options:** `createMockContext()` (minimal), `{ tenantId: 'test-tenant' }` (enables state), `{ sample: vi.fn() }`, `{ elicit: vi.fn() }`, `{ progress: true }` (task progress).
|
|
527
379
|
|
|
528
|
-
**Vitest config:** Extend core config, add `@/` alias: `resolve: { alias: { '@/': new URL('./src/', import.meta.url).pathname } }`.
|
|
529
|
-
|
|
530
|
-
**Isolation:** Construct deps in `beforeEach`. Re-init services per suite. Vitest runs files in separate workers.
|
|
380
|
+
**Vitest config:** Extend core config, add `@/` alias: `resolve: { alias: { '@/': new URL('./src/', import.meta.url).pathname } }`. Construct deps in `beforeEach`. Re-init services per suite.
|
|
531
381
|
|
|
532
382
|
---
|
|
533
383
|
|
|
@@ -563,7 +413,7 @@ Detailed method signatures, options, and examples live in skill files. Read the
|
|
|
563
413
|
|
|
564
414
|
---
|
|
565
415
|
|
|
566
|
-
## Code Style
|
|
416
|
+
## Code Style & Checklist
|
|
567
417
|
|
|
568
418
|
- **Validation:** Zod schemas, all fields need `.describe()`
|
|
569
419
|
- **Logging:** Framework auto-instruments all handler calls. `ctx.log` for domain-specific logging in handlers, global `logger` for lifecycle/background
|
|
@@ -572,6 +422,15 @@ Detailed method signatures, options, and examples live in skill files. Read the
|
|
|
572
422
|
- **Naming:** kebab-case files, snake_case tool/resource/prompt names, correct suffix
|
|
573
423
|
- **JSDoc:** `@fileoverview` + `@module` required on every file
|
|
574
424
|
- **No fabricated signal:** Don't invent synthetic scores or arbitrary "confidence percentages." Surface real signal.
|
|
425
|
+
- **Builders:** `tool()`/`resource()`/`prompt()` with correct fields (`handler`, `input`, `output`, `format`, `auth`, `args`)
|
|
426
|
+
- **Auth:** via `auth: ['scope']` on definitions (not HOF wrapper)
|
|
427
|
+
- **Presence checks:** `ctx.elicit`/`ctx.sample` checked before use
|
|
428
|
+
- **Task tools:** use `task: true` flag
|
|
429
|
+
- **Pagination:** large resource lists use `extractCursor`/`paginateArray`
|
|
430
|
+
- **Registration:** definitions exported in `definitions/index.ts` barrel
|
|
431
|
+
- **Tests:** `createMockContext()`, `.handler()` tested directly
|
|
432
|
+
- **Gate:** `bun run devcheck` passes (includes MCP definition linting)
|
|
433
|
+
- **Smoke-test:** with `dev:stdio`/`dev:http`
|
|
575
434
|
|
|
576
435
|
---
|
|
577
436
|
|
|
@@ -589,6 +448,7 @@ Detailed method signatures, options, and examples live in skill files. Read the
|
|
|
589
448
|
|:--------|:--------|
|
|
590
449
|
| `bun run build` | Build library output (`tsc && tsc-alias`) |
|
|
591
450
|
| `bun run devcheck` | **Use often.** Lint, format, typecheck, security |
|
|
451
|
+
| `bun run lint:mcp` | Validate MCP definitions against spec |
|
|
592
452
|
| `bun run test` | Unit/integration tests |
|
|
593
453
|
| `bun run dev:stdio` | Development mode (stdio) |
|
|
594
454
|
| `bun run dev:http` | Development mode (HTTP) |
|
|
@@ -629,22 +489,3 @@ mcp-publisher publish
|
|
|
629
489
|
When used: `model: "opus"` (preferred) or `"sonnet"` (never `haiku`). Always `run_in_background: true`. Non-overlapping file scope per agent. Agent output not visible to user — orchestrator reports findings. No git commands that modify state.
|
|
630
490
|
|
|
631
491
|
**Required agent preamble:** "CRITICAL: Do NOT run any git commands that modify state. No commits, stashes, resets, checkouts, or clean. Git is handled by the orchestrator. Read-only commands (status, diff, log, show) are acceptable."
|
|
632
|
-
|
|
633
|
-
---
|
|
634
|
-
|
|
635
|
-
## Checklist
|
|
636
|
-
|
|
637
|
-
- [ ] `tool()`/`resource()`/`prompt()` builders with correct fields (`handler`, `input`, `output`, `format`, `auth`, `args`)
|
|
638
|
-
- [ ] Zod schemas: all fields have `.describe()`
|
|
639
|
-
- [ ] JSDoc `@fileoverview` + `@module` on every new/modified file
|
|
640
|
-
- [ ] Auth via `auth: ['scope']` on definitions (not HOF wrapper)
|
|
641
|
-
- [ ] `ctx.log` for domain-specific logging (external calls, business events), `ctx.state` for storage
|
|
642
|
-
- [ ] `ctx.elicit`/`ctx.sample` checked for presence before use
|
|
643
|
-
- [ ] Naming: kebab-case files, snake_case names, correct suffixes
|
|
644
|
-
- [ ] Task tools use `task: true` flag
|
|
645
|
-
- [ ] Large resource lists: `extractCursor`/`paginateArray`
|
|
646
|
-
- [ ] Secrets only in server config
|
|
647
|
-
- [ ] Registered in `definitions/index.ts` barrel
|
|
648
|
-
- [ ] Tests added — `createMockContext()`, `.handler()` tested directly
|
|
649
|
-
- [ ] **`bun run devcheck` passes**
|
|
650
|
-
- [ ] Smoke-tested with `dev:stdio`/`dev:http`
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1>@cyanheads/mcp-ts-core</h1>
|
|
3
|
-
<p><b>TypeScript framework for building
|
|
3
|
+
<p><b>Agent-native TypeScript framework for building MCP servers. Build tools, not infrastructure. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Node.js and Cloudflare Workers.</b></p>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
6
|
<div align="center">
|
|
7
7
|
|
|
8
|
-
[](./CHANGELOG.md) [](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/changelog.mdx) [](https://modelcontextprotocol.io/) [](./LICENSE)
|
|
9
9
|
|
|
10
10
|
[](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
11
11
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
|
-
"description": "TypeScript framework for building
|
|
5
|
+
"description": "Agent-native TypeScript framework for building MCP servers. Build tools, not infrastructure. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Node.js and Cloudflare Workers.",
|
|
6
6
|
"main": "dist/core/index.js",
|
|
7
7
|
"types": "dist/core/index.d.ts",
|
|
8
8
|
"files": [
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts/build.ts",
|
|
11
11
|
"scripts/clean.ts",
|
|
12
12
|
"scripts/devcheck.ts",
|
|
13
|
+
"scripts/lint-mcp.ts",
|
|
13
14
|
"scripts/tree.ts",
|
|
14
15
|
"skills/",
|
|
15
16
|
"templates/",
|
|
@@ -140,6 +141,7 @@
|
|
|
140
141
|
"chrono-node": "2.9.0",
|
|
141
142
|
"diff": "8.0.3",
|
|
142
143
|
"dotenv": "17.3.1",
|
|
144
|
+
"flatted": "3.4.2",
|
|
143
145
|
"hono": "4.12.8",
|
|
144
146
|
"zod": "4.3.6",
|
|
145
147
|
"typescript": "5.9.3"
|
|
@@ -193,25 +195,21 @@
|
|
|
193
195
|
},
|
|
194
196
|
"keywords": [
|
|
195
197
|
"agent",
|
|
198
|
+
"agent-native",
|
|
196
199
|
"ai",
|
|
197
200
|
"ai-agent",
|
|
198
|
-
"authentication",
|
|
199
201
|
"cloudflare-workers",
|
|
200
|
-
"declarative
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"llm-integration",
|
|
202
|
+
"declarative",
|
|
203
|
+
"edge",
|
|
204
|
+
"framework",
|
|
205
|
+
"llm",
|
|
205
206
|
"mcp",
|
|
206
|
-
"model-context-protocol",
|
|
207
207
|
"mcp-server",
|
|
208
|
-
"
|
|
208
|
+
"model-context-protocol",
|
|
209
209
|
"observability",
|
|
210
210
|
"opentelemetry",
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"typescript",
|
|
214
|
-
"zod"
|
|
211
|
+
"tools",
|
|
212
|
+
"typescript"
|
|
215
213
|
],
|
|
216
214
|
"author": "cyanheads <casey@caseyjhand.com> (https://github.com/cyanheads/mcp-ts-core#readme)",
|
|
217
215
|
"license": "Apache-2.0",
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview MCP definition linter CLI.
|
|
4
|
+
* Discovers tool/resource/prompt definitions from conventional locations,
|
|
5
|
+
* runs `validateDefinitions()`, and reports results.
|
|
6
|
+
*
|
|
7
|
+
* Used by devcheck and as a standalone script: `bun run lint:mcp` / `npm run lint:mcp`
|
|
8
|
+
*
|
|
9
|
+
* Discovery strategy:
|
|
10
|
+
* 1. Globs for `*.tool.ts`, `*.resource.ts`, `*.prompt.ts` in known paths
|
|
11
|
+
* 2. Dynamically imports each file
|
|
12
|
+
* 3. Extracts exported definitions by duck-typing (has name/handler/input etc.)
|
|
13
|
+
* 4. Feeds them into `validateDefinitions()`
|
|
14
|
+
*
|
|
15
|
+
* Runtime-agnostic: works with bun, tsx, and Node.js (via ts-node/esm).
|
|
16
|
+
*
|
|
17
|
+
* @module scripts/lint-mcp
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Import validateDefinitions — resolve from package or local source
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
let validateDefinitions: typeof import('../src/linter/validate.js').validateDefinitions;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// Consumer path: installed as a dependency
|
|
30
|
+
const pkg = await import('@cyanheads/mcp-ts-core/linter');
|
|
31
|
+
validateDefinitions = pkg.validateDefinitions;
|
|
32
|
+
} catch {
|
|
33
|
+
// Framework path: running from the framework repo itself
|
|
34
|
+
const local = await import('../src/linter/validate.js');
|
|
35
|
+
validateDefinitions = local.validateDefinitions;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Definition detection (duck-typing)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function isToolLike(v: unknown): boolean {
|
|
43
|
+
if (!v || typeof v !== 'object') return false;
|
|
44
|
+
const o = v as Record<string, unknown>;
|
|
45
|
+
return typeof o.handler === 'function' && o.input != null && o.output != null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isResourceLike(v: unknown): boolean {
|
|
49
|
+
if (!v || typeof v !== 'object') return false;
|
|
50
|
+
const o = v as Record<string, unknown>;
|
|
51
|
+
return typeof o.uriTemplate === 'string' && typeof o.handler === 'function';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPromptLike(v: unknown): boolean {
|
|
55
|
+
if (!v || typeof v !== 'object') return false;
|
|
56
|
+
const o = v as Record<string, unknown>;
|
|
57
|
+
return typeof o.generate === 'function' && typeof o.name === 'string' && !('handler' in o);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// File discovery (no bun dependency — uses Node.js fs)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const SEARCH_DIRS = ['examples/mcp-server', 'src/mcp-server'];
|
|
65
|
+
|
|
66
|
+
const DEFINITION_SUFFIXES = [
|
|
67
|
+
'.tool.ts',
|
|
68
|
+
'.resource.ts',
|
|
69
|
+
'.prompt.ts',
|
|
70
|
+
'.app-tool.ts',
|
|
71
|
+
'.app-resource.ts',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function walkDir(dir: string): string[] {
|
|
75
|
+
const results: string[] = [];
|
|
76
|
+
if (!existsSync(dir)) return results;
|
|
77
|
+
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (entry.name === 'node_modules' || entry.name === '.DS_Store') continue;
|
|
80
|
+
const full = join(dir, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
results.push(...walkDir(full));
|
|
83
|
+
} else if (DEFINITION_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) {
|
|
84
|
+
results.push(full);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function discoverFiles(): string[] {
|
|
91
|
+
const files: string[] = [];
|
|
92
|
+
for (const dir of SEARCH_DIRS) {
|
|
93
|
+
const resolved = resolve(dir);
|
|
94
|
+
files.push(...walkDir(resolved));
|
|
95
|
+
}
|
|
96
|
+
return [...new Set(files)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Main
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async function main(): Promise<void> {
|
|
104
|
+
const files = discoverFiles();
|
|
105
|
+
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
console.log('No MCP definition files found. Skipping lint.');
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tools: unknown[] = [];
|
|
112
|
+
const resources: unknown[] = [];
|
|
113
|
+
const prompts: unknown[] = [];
|
|
114
|
+
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
try {
|
|
117
|
+
const mod = await import(file);
|
|
118
|
+
for (const exported of Object.values(mod)) {
|
|
119
|
+
if (isToolLike(exported)) tools.push(exported);
|
|
120
|
+
else if (isResourceLike(exported)) resources.push(exported);
|
|
121
|
+
else if (isPromptLike(exported)) prompts.push(exported);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`Warning: Failed to import ${file}: ${err instanceof Error ? err.message : err}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const total = tools.length + resources.length + prompts.length;
|
|
131
|
+
if (total === 0) {
|
|
132
|
+
console.log(`Scanned ${files.length} files but found no definitions. Skipping lint.`);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
`Linting ${tools.length} tool(s), ${resources.length} resource(s), ${prompts.length} prompt(s) from ${files.length} file(s)...`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const report = validateDefinitions({ tools, resources, prompts });
|
|
141
|
+
|
|
142
|
+
for (const w of report.warnings) {
|
|
143
|
+
console.warn(` ⚠ [${w.rule}] ${w.message}`);
|
|
144
|
+
}
|
|
145
|
+
for (const e of report.errors) {
|
|
146
|
+
console.error(` ✗ [${e.rule}] ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (report.passed) {
|
|
150
|
+
if (report.warnings.length > 0) {
|
|
151
|
+
console.log(`\nPassed with ${report.warnings.length} warning(s).`);
|
|
152
|
+
} else {
|
|
153
|
+
console.log('\nAll definitions valid.');
|
|
154
|
+
}
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} else {
|
|
157
|
+
console.error(
|
|
158
|
+
`\nFailed: ${report.errors.length} error(s), ${report.warnings.length} warning(s).`,
|
|
159
|
+
);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((err) => {
|
|
165
|
+
console.error('lint-mcp failed:', err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|