feat(core): add isSpecType / specTypeSchemas Records for runtime validation of spec types (#1887)
This commit is contained in:
parent
db83829c5b
commit
96db044fe9
7 changed files with 590 additions and 76 deletions
|
|
@ -138,6 +138,8 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js';
|
|||
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';
|
||||
|
||||
// Validator types and classes
|
||||
export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js';
|
||||
export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js';
|
||||
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
|
||||
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
|
||||
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ export * from './enums.js';
|
|||
export * from './errors.js';
|
||||
export * from './guards.js';
|
||||
export * from './schemas.js';
|
||||
export * from './specTypeSchema.js';
|
||||
export * from './types.js';
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import type {
|
|||
ResultTypeMap
|
||||
} from './types.js';
|
||||
|
||||
export const JSONValueSchema: z.ZodType<JSONValue> = z.lazy(() =>
|
||||
export const JSONValueSchema: z.ZodType<JSONValue, JSONValue> = z.lazy(() =>
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)])
|
||||
);
|
||||
export const JSONObjectSchema: z.ZodType<JSONObject> = z.record(z.string(), JSONValueSchema);
|
||||
export const JSONArraySchema: z.ZodType<JSONArray> = z.array(JSONValueSchema);
|
||||
export const JSONObjectSchema: z.ZodType<JSONObject, JSONObject> = z.record(z.string(), JSONValueSchema);
|
||||
export const JSONArraySchema: z.ZodType<JSONArray, JSONArray> = z.array(JSONValueSchema);
|
||||
/**
|
||||
* A progress token, used to associate progress notifications with the original request.
|
||||
*/
|
||||
|
|
|
|||
40
packages/core/src/types/specTypeSchema.examples.ts
Normal file
40
packages/core/src/types/specTypeSchema.examples.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Type-checked examples for `specTypeSchema.ts`.
|
||||
*
|
||||
* These examples are synced into JSDoc comments via the sync-snippets script.
|
||||
* Each function's region markers define the code snippet that appears in the docs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { isSpecType, specTypeSchemas } from './specTypeSchema.js';
|
||||
|
||||
declare const untrusted: unknown;
|
||||
declare const value: unknown;
|
||||
declare const mixed: unknown[];
|
||||
|
||||
async function specTypeSchemas_basicUsage() {
|
||||
//#region specTypeSchemas_basicUsage
|
||||
const result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);
|
||||
if (result.issues === undefined) {
|
||||
// result.value is CallToolResult
|
||||
}
|
||||
//#endregion specTypeSchemas_basicUsage
|
||||
void result;
|
||||
}
|
||||
|
||||
function isSpecType_basicUsage() {
|
||||
/* eslint-disable unicorn/no-array-callback-reference -- showcasing the guard-as-callback pattern */
|
||||
//#region isSpecType_basicUsage
|
||||
if (isSpecType.ContentBlock(value)) {
|
||||
// value is ContentBlock
|
||||
}
|
||||
|
||||
const blocks = mixed.filter(isSpecType.ContentBlock);
|
||||
//#endregion isSpecType_basicUsage
|
||||
/* eslint-enable unicorn/no-array-callback-reference */
|
||||
void blocks;
|
||||
}
|
||||
|
||||
void specTypeSchemas_basicUsage;
|
||||
void isSpecType_basicUsage;
|
||||
296
packages/core/src/types/specTypeSchema.ts
Normal file
296
packages/core/src/types/specTypeSchema.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import type * as z from 'zod/v4';
|
||||
|
||||
import {
|
||||
OAuthClientInformationFullSchema,
|
||||
OAuthClientInformationSchema,
|
||||
OAuthClientMetadataSchema,
|
||||
OAuthClientRegistrationErrorSchema,
|
||||
OAuthErrorResponseSchema,
|
||||
OAuthMetadataSchema,
|
||||
OAuthProtectedResourceMetadataSchema,
|
||||
OAuthTokenRevocationRequestSchema,
|
||||
OAuthTokensSchema,
|
||||
OpenIdProviderDiscoveryMetadataSchema,
|
||||
OpenIdProviderMetadataSchema
|
||||
} from '../shared/auth.js';
|
||||
import type { StandardSchemaV1 } from '../util/standardSchema.js';
|
||||
import * as schemas from './schemas.js';
|
||||
|
||||
/**
|
||||
* Explicit allowlist of protocol Zod schemas that correspond to a public spec type in `types.ts`.
|
||||
*
|
||||
* This intentionally excludes internal helper schemas exported from `schemas.ts` that have no
|
||||
* matching public type (e.g. `ListChangedOptionsBaseSchema`, `BaseRequestParamsSchema`,
|
||||
* `NotificationsParamsSchema`, `ClientTasksCapabilitySchema`, `ServerTasksCapabilitySchema`).
|
||||
* Keeping the list explicit means new public spec types must be added here deliberately, and
|
||||
* internals never leak into `SpecTypeName`.
|
||||
*
|
||||
* `ResourceTemplateSchema` is included; its public type is exported as `ResourceTemplateType`
|
||||
* (the bare name collides with the server package's `ResourceTemplate` class), so
|
||||
* `SpecTypes['ResourceTemplate']` is structurally equal to `ResourceTemplateType` rather than to
|
||||
* a type literally named `ResourceTemplate`.
|
||||
*/
|
||||
const SPEC_SCHEMA_KEYS = [
|
||||
'AnnotationsSchema',
|
||||
'AudioContentSchema',
|
||||
'BaseMetadataSchema',
|
||||
'BlobResourceContentsSchema',
|
||||
'BooleanSchemaSchema',
|
||||
'CallToolRequestSchema',
|
||||
'CallToolRequestParamsSchema',
|
||||
'CallToolResultSchema',
|
||||
'CancelledNotificationSchema',
|
||||
'CancelledNotificationParamsSchema',
|
||||
'CancelTaskRequestSchema',
|
||||
'CancelTaskResultSchema',
|
||||
'ClientCapabilitiesSchema',
|
||||
'ClientNotificationSchema',
|
||||
'ClientRequestSchema',
|
||||
'ClientResultSchema',
|
||||
'CompatibilityCallToolResultSchema',
|
||||
'CompleteRequestSchema',
|
||||
'CompleteRequestParamsSchema',
|
||||
'CompleteResultSchema',
|
||||
'ContentBlockSchema',
|
||||
'CreateMessageRequestSchema',
|
||||
'CreateMessageRequestParamsSchema',
|
||||
'CreateMessageResultSchema',
|
||||
'CreateMessageResultWithToolsSchema',
|
||||
'CreateTaskResultSchema',
|
||||
'CursorSchema',
|
||||
'ElicitationCompleteNotificationSchema',
|
||||
'ElicitationCompleteNotificationParamsSchema',
|
||||
'ElicitRequestSchema',
|
||||
'ElicitRequestFormParamsSchema',
|
||||
'ElicitRequestParamsSchema',
|
||||
'ElicitRequestURLParamsSchema',
|
||||
'ElicitResultSchema',
|
||||
'EmbeddedResourceSchema',
|
||||
'EmptyResultSchema',
|
||||
'EnumSchemaSchema',
|
||||
'GetPromptRequestSchema',
|
||||
'GetPromptRequestParamsSchema',
|
||||
'GetPromptResultSchema',
|
||||
'GetTaskPayloadRequestSchema',
|
||||
'GetTaskPayloadResultSchema',
|
||||
'GetTaskRequestSchema',
|
||||
'GetTaskResultSchema',
|
||||
'IconSchema',
|
||||
'IconsSchema',
|
||||
'ImageContentSchema',
|
||||
'ImplementationSchema',
|
||||
'InitializedNotificationSchema',
|
||||
'InitializeRequestSchema',
|
||||
'InitializeRequestParamsSchema',
|
||||
'InitializeResultSchema',
|
||||
'JSONArraySchema',
|
||||
'JSONObjectSchema',
|
||||
'JSONRPCErrorResponseSchema',
|
||||
'JSONRPCMessageSchema',
|
||||
'JSONRPCNotificationSchema',
|
||||
'JSONRPCRequestSchema',
|
||||
'JSONRPCResponseSchema',
|
||||
'JSONRPCResultResponseSchema',
|
||||
'JSONValueSchema',
|
||||
'LegacyTitledEnumSchemaSchema',
|
||||
'ListPromptsRequestSchema',
|
||||
'ListPromptsResultSchema',
|
||||
'ListResourcesRequestSchema',
|
||||
'ListResourcesResultSchema',
|
||||
'ListResourceTemplatesRequestSchema',
|
||||
'ListResourceTemplatesResultSchema',
|
||||
'ListRootsRequestSchema',
|
||||
'ListRootsResultSchema',
|
||||
'ListTasksRequestSchema',
|
||||
'ListTasksResultSchema',
|
||||
'ListToolsRequestSchema',
|
||||
'ListToolsResultSchema',
|
||||
'LoggingLevelSchema',
|
||||
'LoggingMessageNotificationSchema',
|
||||
'LoggingMessageNotificationParamsSchema',
|
||||
'ModelHintSchema',
|
||||
'ModelPreferencesSchema',
|
||||
'MultiSelectEnumSchemaSchema',
|
||||
'NotificationSchema',
|
||||
'NumberSchemaSchema',
|
||||
'PaginatedRequestSchema',
|
||||
'PaginatedRequestParamsSchema',
|
||||
'PaginatedResultSchema',
|
||||
'PingRequestSchema',
|
||||
'PrimitiveSchemaDefinitionSchema',
|
||||
'ProgressSchema',
|
||||
'ProgressNotificationSchema',
|
||||
'ProgressNotificationParamsSchema',
|
||||
'ProgressTokenSchema',
|
||||
'PromptSchema',
|
||||
'PromptArgumentSchema',
|
||||
'PromptListChangedNotificationSchema',
|
||||
'PromptMessageSchema',
|
||||
'PromptReferenceSchema',
|
||||
'ReadResourceRequestSchema',
|
||||
'ReadResourceRequestParamsSchema',
|
||||
'ReadResourceResultSchema',
|
||||
'RelatedTaskMetadataSchema',
|
||||
'RequestSchema',
|
||||
'RequestIdSchema',
|
||||
'RequestMetaSchema',
|
||||
'ResourceSchema',
|
||||
'ResourceContentsSchema',
|
||||
'ResourceLinkSchema',
|
||||
'ResourceListChangedNotificationSchema',
|
||||
'ResourceRequestParamsSchema',
|
||||
'ResourceTemplateSchema',
|
||||
'ResourceTemplateReferenceSchema',
|
||||
'ResourceUpdatedNotificationSchema',
|
||||
'ResourceUpdatedNotificationParamsSchema',
|
||||
'ResultSchema',
|
||||
'RoleSchema',
|
||||
'RootSchema',
|
||||
'RootsListChangedNotificationSchema',
|
||||
'SamplingContentSchema',
|
||||
'SamplingMessageSchema',
|
||||
'SamplingMessageContentBlockSchema',
|
||||
'ServerCapabilitiesSchema',
|
||||
'ServerNotificationSchema',
|
||||
'ServerRequestSchema',
|
||||
'ServerResultSchema',
|
||||
'SetLevelRequestSchema',
|
||||
'SetLevelRequestParamsSchema',
|
||||
'SingleSelectEnumSchemaSchema',
|
||||
'StringSchemaSchema',
|
||||
'SubscribeRequestSchema',
|
||||
'SubscribeRequestParamsSchema',
|
||||
'TaskSchema',
|
||||
'TaskAugmentedRequestParamsSchema',
|
||||
'TaskCreationParamsSchema',
|
||||
'TaskMetadataSchema',
|
||||
'TaskStatusSchema',
|
||||
'TaskStatusNotificationSchema',
|
||||
'TaskStatusNotificationParamsSchema',
|
||||
'TextContentSchema',
|
||||
'TextResourceContentsSchema',
|
||||
'TitledMultiSelectEnumSchemaSchema',
|
||||
'TitledSingleSelectEnumSchemaSchema',
|
||||
'ToolSchema',
|
||||
'ToolAnnotationsSchema',
|
||||
'ToolChoiceSchema',
|
||||
'ToolExecutionSchema',
|
||||
'ToolListChangedNotificationSchema',
|
||||
'ToolResultContentSchema',
|
||||
'ToolUseContentSchema',
|
||||
'UnsubscribeRequestSchema',
|
||||
'UnsubscribeRequestParamsSchema',
|
||||
'UntitledMultiSelectEnumSchemaSchema',
|
||||
'UntitledSingleSelectEnumSchemaSchema'
|
||||
] as const satisfies readonly (keyof typeof schemas)[];
|
||||
|
||||
const authSchemas = {
|
||||
OAuthClientInformationFullSchema,
|
||||
OAuthClientInformationSchema,
|
||||
OAuthClientMetadataSchema,
|
||||
OAuthClientRegistrationErrorSchema,
|
||||
OAuthErrorResponseSchema,
|
||||
OAuthMetadataSchema,
|
||||
OAuthProtectedResourceMetadataSchema,
|
||||
OAuthTokenRevocationRequestSchema,
|
||||
OAuthTokensSchema,
|
||||
OpenIdProviderDiscoveryMetadataSchema,
|
||||
OpenIdProviderMetadataSchema
|
||||
} as const;
|
||||
|
||||
type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number];
|
||||
type AuthSchemaKey = keyof typeof authSchemas;
|
||||
type SchemaKey = ProtocolSchemaKey | AuthSchemaKey;
|
||||
|
||||
type SchemaFor<K extends SchemaKey> = K extends ProtocolSchemaKey
|
||||
? (typeof schemas)[K]
|
||||
: K extends AuthSchemaKey
|
||||
? (typeof authSchemas)[K]
|
||||
: never;
|
||||
|
||||
type StripSchemaSuffix<K> = K extends `${infer N}Schema` ? N : never;
|
||||
|
||||
/**
|
||||
* Union of every named type in the SDK's protocol and OAuth schemas (e.g. `'CallToolResult'`,
|
||||
* `'ContentBlock'`, `'Tool'`, `'OAuthTokens'`). Derived from the internal Zod schemas, so it stays
|
||||
* in sync with the spec.
|
||||
*/
|
||||
export type SpecTypeName = StripSchemaSuffix<SchemaKey>;
|
||||
|
||||
/**
|
||||
* Maps each {@linkcode SpecTypeName} to its TypeScript type.
|
||||
*
|
||||
* `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly.
|
||||
*/
|
||||
export type SpecTypes = {
|
||||
[K in SchemaKey as StripSchemaSuffix<K>]: SchemaFor<K> extends z.ZodType ? z.output<SchemaFor<K>> : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input shape for each {@linkcode SpecTypeName}. For most types this equals {@linkcode SpecTypes},
|
||||
* but a few schemas apply defaults/preprocessing, so the accepted input may be looser than the
|
||||
* resulting output type.
|
||||
*/
|
||||
type SpecTypeInputs = {
|
||||
[K in SchemaKey as StripSchemaSuffix<K>]: SchemaFor<K> extends z.ZodType ? z.input<SchemaFor<K>> : never;
|
||||
};
|
||||
|
||||
type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1<SpecTypeInputs[K], SpecTypes[K]> };
|
||||
type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] };
|
||||
|
||||
const _specTypeSchemas: Record<string, z.ZodTypeAny> = {};
|
||||
const _isSpecType: Record<string, (value: unknown) => boolean> = {};
|
||||
function register(key: string, schema: z.ZodTypeAny): void {
|
||||
const name = key.slice(0, -'Schema'.length);
|
||||
_specTypeSchemas[name] = schema;
|
||||
_isSpecType[name] = (v: unknown) => schema.safeParse(v).success;
|
||||
}
|
||||
for (const key of SPEC_SCHEMA_KEYS) {
|
||||
// eslint-disable-next-line import/namespace -- key is constrained to keyof typeof schemas via the satisfies clause above
|
||||
register(key, schemas[key]);
|
||||
}
|
||||
for (const [key, schema] of Object.entries(authSchemas)) {
|
||||
register(key, schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime validators for every MCP spec type, keyed by type name.
|
||||
*
|
||||
* Use this when you need to validate a spec-defined shape at a boundary the SDK does not own, for
|
||||
* example an extension's custom-method payload that embeds a `CallToolResult`, or a value read from
|
||||
* storage that should be a `Tool`.
|
||||
*
|
||||
* Each entry implements the Standard Schema interface, so it composes with any
|
||||
* Standard-Schema-aware library. For a simple boolean check, use {@linkcode isSpecType} instead.
|
||||
*
|
||||
* @example
|
||||
* ```ts source="./specTypeSchema.examples.ts#specTypeSchemas_basicUsage"
|
||||
* const result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);
|
||||
* if (result.issues === undefined) {
|
||||
* // result.value is CallToolResult
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas) as unknown as SchemaRecord;
|
||||
|
||||
/**
|
||||
* Type predicates for every MCP spec type, keyed by type name.
|
||||
*
|
||||
* Returns `true` if the value satisfies the schema's input type (`z.input<>`, before defaults and
|
||||
* transforms are applied), and narrows to that input type. For schemas with `.default()` or
|
||||
* `.preprocess()`, this may accept values that do not structurally match the named output type;
|
||||
* for example `isSpecType.CallToolResult({})` is `true` because `content` has a default. Use
|
||||
* `specTypeSchemas.X['~standard'].validate(value)` when you need the validated output value.
|
||||
*
|
||||
* Each guard is a standalone function, so it can be passed directly as a callback.
|
||||
*
|
||||
* @example
|
||||
* ```ts source="./specTypeSchema.examples.ts#isSpecType_basicUsage"
|
||||
* if (isSpecType.ContentBlock(value)) {
|
||||
* // value is ContentBlock
|
||||
* }
|
||||
*
|
||||
* const blocks = mixed.filter(isSpecType.ContentBlock);
|
||||
* ```
|
||||
*/
|
||||
export const isSpecType: GuardRecord = Object.freeze(_isSpecType) as unknown as GuardRecord;
|
||||
123
packages/core/test/types/guards.test.ts
Normal file
123
packages/core/test/types/guards.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { JSONRPC_VERSION } from '../../src/types/constants.js';
|
||||
import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from '../../src/types/guards.js';
|
||||
|
||||
describe('isJSONRPCResponse', () => {
|
||||
it('returns true for a valid result response', () => {
|
||||
expect(
|
||||
isJSONRPCResponse({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 1,
|
||||
result: {}
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a valid error response', () => {
|
||||
expect(
|
||||
isJSONRPCResponse({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 1,
|
||||
error: { code: -32_600, message: 'Invalid Request' }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a request', () => {
|
||||
expect(
|
||||
isJSONRPCResponse({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 1,
|
||||
method: 'test'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a notification', () => {
|
||||
expect(
|
||||
isJSONRPCResponse({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
method: 'test'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for arbitrary objects', () => {
|
||||
expect(isJSONRPCResponse({ foo: 'bar' })).toBe(false);
|
||||
});
|
||||
|
||||
it('narrows the type correctly', () => {
|
||||
const value: unknown = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 1,
|
||||
result: { content: [] }
|
||||
};
|
||||
if (isJSONRPCResponse(value)) {
|
||||
// Type should be narrowed to JSONRPCResponse
|
||||
expect(value.jsonrpc).toBe(JSONRPC_VERSION);
|
||||
expect(value.id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with isJSONRPCResultResponse || isJSONRPCErrorResponse', () => {
|
||||
const values = [
|
||||
{ jsonrpc: JSONRPC_VERSION, id: 1, result: {} },
|
||||
{ jsonrpc: JSONRPC_VERSION, id: 2, error: { code: -1, message: 'err' } },
|
||||
{ jsonrpc: JSONRPC_VERSION, id: 3, method: 'test' },
|
||||
{ jsonrpc: JSONRPC_VERSION, method: 'notify' },
|
||||
{ foo: 'bar' },
|
||||
null,
|
||||
42
|
||||
];
|
||||
for (const v of values) {
|
||||
expect(isJSONRPCResponse(v)).toBe(isJSONRPCResultResponse(v) || isJSONRPCErrorResponse(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCallToolResult', () => {
|
||||
it('returns false for an empty object (content is required)', () => {
|
||||
expect(isCallToolResult({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a result with content', () => {
|
||||
expect(
|
||||
isCallToolResult({
|
||||
content: [{ type: 'text', text: 'hello' }]
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a result with isError', () => {
|
||||
expect(
|
||||
isCallToolResult({
|
||||
content: [{ type: 'text', text: 'fail' }],
|
||||
isError: true
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a result with structuredContent', () => {
|
||||
expect(
|
||||
isCallToolResult({
|
||||
content: [],
|
||||
structuredContent: { key: 'value' }
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-objects', () => {
|
||||
expect(isCallToolResult(null)).toBe(false);
|
||||
expect(isCallToolResult(42)).toBe(false);
|
||||
expect(isCallToolResult('string')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for invalid content items', () => {
|
||||
expect(
|
||||
isCallToolResult({
|
||||
content: [{ type: 'invalid' }]
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
148
packages/core/test/types/specTypeSchema.test.ts
Normal file
148
packages/core/test/types/specTypeSchema.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||
|
||||
import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js';
|
||||
import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js';
|
||||
import { isSpecType, specTypeSchemas } from '../../src/types/specTypeSchema.js';
|
||||
import type {
|
||||
CallToolResult,
|
||||
ContentBlock,
|
||||
Implementation,
|
||||
JSONObject,
|
||||
JSONRPCRequest,
|
||||
JSONValue,
|
||||
ResourceTemplateType,
|
||||
Tool
|
||||
} from '../../src/types/types.js';
|
||||
|
||||
describe('specTypeSchemas', () => {
|
||||
it('returns a StandardSchemaV1 validator that accepts valid values', () => {
|
||||
const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x', version: '1.0.0' });
|
||||
expect((result as { issues?: unknown }).issues).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a validator that rejects invalid values with issues', () => {
|
||||
const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x' });
|
||||
expect((result as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects unknown names at compile time and is undefined at runtime', () => {
|
||||
// @ts-expect-error - 'NotASpecType' is not a SpecTypeName
|
||||
expect(specTypeSchemas['NotASpecType']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers JSON-RPC envelope types', () => {
|
||||
const ok = specTypeSchemas.JSONRPCRequest['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' });
|
||||
expect((ok as { issues?: unknown }).issues).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers OAuth types from shared/auth.ts', () => {
|
||||
const ok = specTypeSchemas.OAuthTokens['~standard'].validate({ access_token: 'x', token_type: 'Bearer' });
|
||||
expect((ok as { issues?: unknown }).issues).toBeUndefined();
|
||||
const bad = specTypeSchemas.OAuthTokens['~standard'].validate({ token_type: 'Bearer' });
|
||||
expect((bad as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSpecType', () => {
|
||||
it('CallToolResult — accepts valid, rejects invalid/null/primitive', () => {
|
||||
expect(isSpecType.CallToolResult({ content: [{ type: 'text', text: 'hi' }] })).toBe(true);
|
||||
expect(isSpecType.CallToolResult({ content: 'not-an-array' })).toBe(false);
|
||||
expect(isSpecType.CallToolResult(null)).toBe(false);
|
||||
expect(isSpecType.CallToolResult('string')).toBe(false);
|
||||
});
|
||||
|
||||
it('ContentBlock — accepts text block, rejects wrong shape', () => {
|
||||
expect(isSpecType.ContentBlock({ type: 'text', text: 'hi' })).toBe(true);
|
||||
expect(isSpecType.ContentBlock({ type: 'text' })).toBe(false);
|
||||
expect(isSpecType.ContentBlock({})).toBe(false);
|
||||
});
|
||||
|
||||
it('Tool — accepts valid, rejects missing inputSchema', () => {
|
||||
expect(isSpecType.Tool({ name: 'echo', inputSchema: { type: 'object' } })).toBe(true);
|
||||
expect(isSpecType.Tool({ name: 'echo' })).toBe(false);
|
||||
});
|
||||
|
||||
it('ResourceTemplate — accepts valid, rejects missing uriTemplate', () => {
|
||||
expect(isSpecType.ResourceTemplate({ name: 'r', uriTemplate: 'file:///{path}' })).toBe(true);
|
||||
expect(isSpecType.ResourceTemplate({ name: 'r' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown names at compile time and is undefined at runtime', () => {
|
||||
// @ts-expect-error - 'NotASpecType' is not a SpecTypeName
|
||||
expect(isSpecType['NotASpecType']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('excludes internal helper schemas (no matching public type)', () => {
|
||||
// @ts-expect-error - ListChangedOptionsBase is internal-only
|
||||
expect(isSpecType['ListChangedOptionsBase']).toBeUndefined();
|
||||
// @ts-expect-error - BaseRequestParams is internal-only
|
||||
expect(specTypeSchemas['BaseRequestParams']).toBeUndefined();
|
||||
// @ts-expect-error - NotificationsParams is internal-only
|
||||
expect(isSpecType['NotificationsParams']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('narrows the value type to the schema input type', () => {
|
||||
const v: unknown = { name: 'x', version: '1.0.0' };
|
||||
if (isSpecType.Implementation(v)) {
|
||||
// ImplementationSchema has no defaults/transforms, so its input type equals Implementation.
|
||||
expectTypeOf(v).toEqualTypeOf<Implementation>();
|
||||
}
|
||||
});
|
||||
|
||||
it('narrows to the input type, not the output type, for schemas with defaults', () => {
|
||||
const v: unknown = {};
|
||||
expect(isSpecType.CallToolResult(v)).toBe(true);
|
||||
if (isSpecType.CallToolResult(v)) {
|
||||
// CallToolResultSchema has `content: z.array(...).default([])`, so the input type
|
||||
// permits `content` to be absent. The guard narrows to that input shape.
|
||||
expectTypeOf(v.content).toEqualTypeOf<ContentBlock[] | undefined>();
|
||||
expectTypeOf(v).not.toEqualTypeOf<CallToolResult>();
|
||||
}
|
||||
});
|
||||
|
||||
it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => {
|
||||
// These schemas use an explicit z.ZodType<T, T> annotation for recursion; without the
|
||||
// second param Zod's Input defaults to `unknown` and the predicate would not narrow.
|
||||
const v: unknown = { a: 1 };
|
||||
if (isSpecType.JSONValue(v)) {
|
||||
expectTypeOf(v).toEqualTypeOf<JSONValue>();
|
||||
}
|
||||
if (isSpecType.JSONObject(v)) {
|
||||
expectTypeOf(v).toEqualTypeOf<JSONObject>();
|
||||
}
|
||||
});
|
||||
|
||||
it('guards work as filter callbacks and narrow the element type', () => {
|
||||
const mixed: unknown[] = [{ type: 'text', text: 'hi' }, 42, { type: 'text' }];
|
||||
const blocks = mixed.filter(isSpecType.ContentBlock);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expectTypeOf(blocks).toEqualTypeOf<ContentBlock[]>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpecTypeName / SpecTypes (type-level)', () => {
|
||||
it('SpecTypeName includes representative names', () => {
|
||||
expectTypeOf<'CallToolResult'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'ContentBlock'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'Tool'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'Implementation'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'JSONRPCRequest'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'OAuthTokens'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'OAuthMetadata'>().toMatchTypeOf<SpecTypeName>();
|
||||
expectTypeOf<'ResourceTemplate'>().toMatchTypeOf<SpecTypeName>();
|
||||
});
|
||||
|
||||
it('SpecTypes[K] matches the named export type', () => {
|
||||
expectTypeOf<SpecTypes['CallToolResult']>().toEqualTypeOf<CallToolResult>();
|
||||
expectTypeOf<SpecTypes['ContentBlock']>().toEqualTypeOf<ContentBlock>();
|
||||
expectTypeOf<SpecTypes['Tool']>().toEqualTypeOf<Tool>();
|
||||
expectTypeOf<SpecTypes['Implementation']>().toEqualTypeOf<Implementation>();
|
||||
expectTypeOf<SpecTypes['JSONRPCRequest']>().toEqualTypeOf<JSONRPCRequest>();
|
||||
expectTypeOf<SpecTypes['OAuthTokens']>().toEqualTypeOf<OAuthTokens>();
|
||||
expectTypeOf<SpecTypes['OAuthMetadata']>().toEqualTypeOf<OAuthMetadata>();
|
||||
// The public type is exported as ResourceTemplateType (the bare name collides with the
|
||||
// server package's ResourceTemplate class), so this is the one entry where the key and
|
||||
// the public type name differ.
|
||||
expectTypeOf<SpecTypes['ResourceTemplate']>().toEqualTypeOf<ResourceTemplateType>();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue