v2 backcompat: server legacy package (#2206)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
parent
83a54a454d
commit
e03bca90c1
46 changed files with 5797 additions and 71 deletions
|
|
@ -9,7 +9,8 @@ const PACKAGE_DIRS: Record<string, string> = {
|
|||
'@modelcontextprotocol/client': 'client',
|
||||
'@modelcontextprotocol/server': 'server',
|
||||
'@modelcontextprotocol/node': 'middleware/node',
|
||||
'@modelcontextprotocol/express': 'middleware/express'
|
||||
'@modelcontextprotocol/express': 'middleware/express',
|
||||
'@modelcontextprotocol/server-legacy': 'server-legacy'
|
||||
};
|
||||
|
||||
const versions: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ export const V2_PACKAGE_VERSIONS: Record<string, string> = {
|
|||
'@modelcontextprotocol/client': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/express': '^2.0.0-alpha.2'
|
||||
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
|
||||
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface ImportMapping {
|
|||
removalMessage?: string;
|
||||
/** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */
|
||||
isV2Gap?: boolean;
|
||||
/** Emitted as an info diagnostic after a successful move, suggesting eventual migration to v2 equivalents. */
|
||||
migrationHint?: string;
|
||||
/**
|
||||
* Subpath suffix appended after `RESOLVE_BY_CONTEXT` resolves the base package (e.g. `/validators/ajv`).
|
||||
* The final target becomes `@modelcontextprotocol/{client,server}<subpathSuffix>`.
|
||||
|
|
@ -74,9 +76,10 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
status: 'moved'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/sse.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage: 'SSE server transport removed in v2. Migrate to NodeStreamableHTTPServerTransport from @modelcontextprotocol/node.'
|
||||
target: '@modelcontextprotocol/server-legacy/sse',
|
||||
status: 'moved',
|
||||
migrationHint:
|
||||
'SSEServerTransport is deprecated. Migrate to NodeStreamableHTTPServerTransport from @modelcontextprotocol/node or WebStandardStreamableHTTPServerTransport from @modelcontextprotocol/server.'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/middleware.js': {
|
||||
target: '@modelcontextprotocol/express',
|
||||
|
|
@ -90,34 +93,29 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
|
|||
},
|
||||
|
||||
'@modelcontextprotocol/sdk/server/auth/types.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth removed in v2. AuthInfo is now re-exported by @modelcontextprotocol/client and @modelcontextprotocol/server.'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy auth types. AuthInfo is also re-exported by @modelcontextprotocol/server.'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/auth/provider.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth provider removed in v2. For Resource-Server auth (token verification), see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/auth/router.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth router removed in v2. For metadata endpoints, see mcpAuthMetadataRouter from @modelcontextprotocol/express. For full OAuth AS router, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy OAuth AS router. For metadata-only endpoints, see mcpAuthMetadataRouter from @modelcontextprotocol/express.'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/auth/middleware.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth middleware removed in v2. For bearer token validation, see requireBearerAuth from @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
|
||||
},
|
||||
'@modelcontextprotocol/sdk/server/auth/errors.js': {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Auth error subclasses consolidated in v2. Use OAuthError + OAuthErrorCode from @modelcontextprotocol/server. See also @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy error subclasses. v2 consolidates to OAuthError + OAuthErrorCode in @modelcontextprotocol/server.'
|
||||
},
|
||||
|
||||
'@modelcontextprotocol/sdk/types.js': {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { SourceFile } from 'ts-morph';
|
|||
|
||||
import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js';
|
||||
import { renameAllReferences } from '../../../utils/astUtils.js';
|
||||
import { v2Gap, warning } from '../../../utils/diagnostics.js';
|
||||
import { info, v2Gap, warning } from '../../../utils/diagnostics.js';
|
||||
import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js';
|
||||
import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js';
|
||||
import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js';
|
||||
|
|
@ -75,10 +75,9 @@ export const importPathsTransform: Transform = {
|
|||
|
||||
if (!mapping && isAuthImport(specifier)) {
|
||||
mapping = {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth removed in v2. For RS auth, see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy auth module. For RS-only auth, see @modelcontextprotocol/express.'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +155,9 @@ export const importPathsTransform: Transform = {
|
|||
}
|
||||
}
|
||||
changesCount++;
|
||||
if (mapping.migrationHint) {
|
||||
diagnostics.push(info(filePath, line, mapping.migrationHint));
|
||||
}
|
||||
for (const [oldName, newName] of symbolsToRenameInFile) {
|
||||
renameAllReferences(sourceFile, oldName, newName);
|
||||
}
|
||||
|
|
@ -172,6 +174,9 @@ export const importPathsTransform: Transform = {
|
|||
}
|
||||
imp.remove();
|
||||
changesCount++;
|
||||
if (mapping.migrationHint) {
|
||||
diagnostics.push(info(filePath, line, mapping.migrationHint));
|
||||
}
|
||||
for (const [oldName, newName] of symbolsToRenameInFile) {
|
||||
renameAllReferences(sourceFile, oldName, newName);
|
||||
}
|
||||
|
|
@ -222,10 +227,9 @@ function rewriteExportDeclarations(
|
|||
|
||||
if (!mapping && isAuthImport(specifier)) {
|
||||
mapping = {
|
||||
target: '',
|
||||
status: 'removed',
|
||||
removalMessage:
|
||||
'Server auth removed in v2. For RS auth, see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).'
|
||||
target: '@modelcontextprotocol/server-legacy/auth',
|
||||
status: 'moved',
|
||||
migrationHint: 'Legacy auth module. For RS-only auth, see @modelcontextprotocol/express.'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -288,6 +292,9 @@ function rewriteExportDeclarations(
|
|||
}
|
||||
}
|
||||
changesCount++;
|
||||
if (mapping.migrationHint) {
|
||||
diagnostics.push(info(filePath, line, mapping.migrationHint));
|
||||
}
|
||||
}
|
||||
|
||||
return changesCount;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ function resolveTarget(
|
|||
| { removed: true; isV2Gap?: boolean; removalMessage?: string }
|
||||
| null {
|
||||
const mapping = IMPORT_MAP[specifier];
|
||||
if (!mapping && isAuthImport(specifier)) return { removed: true };
|
||||
if (!mapping && isAuthImport(specifier)) return { target: '@modelcontextprotocol/server-legacy/auth' };
|
||||
if (!mapping) return null;
|
||||
if (mapping.status === 'removed') return { removed: true, isV2Gap: mapping.isV2Gap, removalMessage: mapping.removalMessage };
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('CLI diagnostic behavior', () => {
|
||||
it('warnings do not produce errors-level diagnostics', () => {
|
||||
it('info diagnostics do not produce error-level diagnostics', () => {
|
||||
const dir = createTempDir();
|
||||
writeFileSync(
|
||||
path.join(dir, 'server.ts'),
|
||||
|
|
@ -36,9 +36,9 @@ describe('CLI diagnostic behavior', () => {
|
|||
|
||||
const result = run(migration, { targetDir: dir });
|
||||
|
||||
const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning);
|
||||
const infos = result.diagnostics.filter(d => d.level === DiagnosticLevel.Info);
|
||||
const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
expect(infos.length).toBeGreaterThan(0);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -573,7 +573,7 @@ describe('integration', () => {
|
|||
expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk');
|
||||
});
|
||||
|
||||
it('emits diagnostics for removed imports', () => {
|
||||
it('emits info diagnostics for legacy-moved imports', () => {
|
||||
const dir = createTempDir();
|
||||
const input = [
|
||||
`import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`,
|
||||
|
|
@ -586,7 +586,7 @@ describe('integration', () => {
|
|||
const result = run(migration, { targetDir: dir });
|
||||
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Warning)).toBe(true);
|
||||
expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Info)).toBe(true);
|
||||
});
|
||||
|
||||
it('transform ordering: critical dependencies are maintained', () => {
|
||||
|
|
|
|||
|
|
@ -59,14 +59,19 @@ describe('import-paths transform', () => {
|
|||
expect(result.diagnostics[0]!.message).toContain('WebSocketClientTransport');
|
||||
});
|
||||
|
||||
it('removes SSE server import with warning', () => {
|
||||
it('moves SSE server import to server-legacy/sse with info diagnostic', () => {
|
||||
const input = `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\n`;
|
||||
const ctx: TransformContext = { projectType: 'server' };
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = importPathsTransform.apply(sourceFile, ctx);
|
||||
const output = sourceFile.getFullText();
|
||||
expect(output).toContain('@modelcontextprotocol/server-legacy/sse');
|
||||
expect(output).toContain('SSEServerTransport');
|
||||
expect(output).not.toContain('@modelcontextprotocol/sdk');
|
||||
expect(result.changesCount).toBeGreaterThan(0);
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics[0]!.message).toContain('SSE server transport');
|
||||
expect(result.diagnostics[0]!.message).toContain('SSEServerTransport is deprecated');
|
||||
});
|
||||
|
||||
it('resolves sdk/types.js based on sibling client imports', () => {
|
||||
|
|
@ -270,13 +275,79 @@ describe('import-paths transform', () => {
|
|||
expect(result).toContain('NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport');
|
||||
});
|
||||
|
||||
it('removes auth imports with warning', () => {
|
||||
it('moves auth router import to server-legacy/auth with info diagnostic', () => {
|
||||
const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\n`;
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
|
||||
const output = sourceFile.getFullText();
|
||||
expect(output).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
expect(output).toContain('mcpAuthRouter');
|
||||
expect(output).not.toContain('@modelcontextprotocol/sdk');
|
||||
expect(result.changesCount).toBeGreaterThan(0);
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics[0]!.message).toContain('auth router removed');
|
||||
expect(result.diagnostics[0]!.message).toContain('Legacy OAuth AS router');
|
||||
});
|
||||
|
||||
it('moves auth provider import to server-legacy/auth', () => {
|
||||
const input = `import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js';\n`;
|
||||
const result = applyTransform(input, { projectType: 'server' });
|
||||
expect(result).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
expect(result).toContain('OAuthServerProvider');
|
||||
expect(result).not.toContain('@modelcontextprotocol/sdk');
|
||||
});
|
||||
|
||||
it('moves auth middleware import to server-legacy/auth', () => {
|
||||
const input = `import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware.js';\n`;
|
||||
const result = applyTransform(input, { projectType: 'server' });
|
||||
expect(result).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
expect(result).toContain('requireBearerAuth');
|
||||
expect(result).not.toContain('@modelcontextprotocol/sdk');
|
||||
});
|
||||
|
||||
it('moves auth errors import to server-legacy/auth', () => {
|
||||
const input = `import { InvalidTokenError, OAuthError } from '@modelcontextprotocol/sdk/server/auth/errors.js';\n`;
|
||||
const result = applyTransform(input, { projectType: 'server' });
|
||||
expect(result).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
expect(result).toContain('InvalidTokenError');
|
||||
expect(result).toContain('OAuthError');
|
||||
expect(result).not.toContain('@modelcontextprotocol/sdk');
|
||||
});
|
||||
|
||||
it('consolidates multiple auth subpath imports into single server-legacy/auth import', () => {
|
||||
const input = [
|
||||
`import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';`,
|
||||
`import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware.js';`,
|
||||
''
|
||||
].join('\n');
|
||||
const result = applyTransform(input, { projectType: 'server' });
|
||||
expect(result).toContain('mcpAuthRouter');
|
||||
expect(result).toContain('requireBearerAuth');
|
||||
expect(result).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
const importLines = result.split('\n').filter((l: string) => l.includes('@modelcontextprotocol/server-legacy/auth'));
|
||||
expect(importLines.length).toBe(1);
|
||||
});
|
||||
|
||||
it('moves SSE server re-export to server-legacy/sse', () => {
|
||||
const input = `export { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\n`;
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
|
||||
const output = sourceFile.getFullText();
|
||||
expect(output).toContain('@modelcontextprotocol/server-legacy/sse');
|
||||
expect(output).toContain('SSEServerTransport');
|
||||
expect(result.diagnostics.some(d => d.message.includes('SSEServerTransport is deprecated'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes server-legacy in usedPackages for SSE import', () => {
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile(
|
||||
'test.ts',
|
||||
`import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\n`
|
||||
);
|
||||
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
|
||||
expect(result.usedPackages).toBeDefined();
|
||||
expect(result.usedPackages!.has('@modelcontextprotocol/server-legacy/sse')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles per-specifier type modifiers', () => {
|
||||
|
|
@ -373,14 +444,17 @@ describe('import-paths transform', () => {
|
|||
expect(result.diagnostics[0]!.message).toContain('removed');
|
||||
});
|
||||
|
||||
it('handles unknown auth subpath via catch-all', () => {
|
||||
it('moves unknown auth subpath to server-legacy/auth via catch-all', () => {
|
||||
const input = `import { SomeType } from '@modelcontextprotocol/sdk/server/auth/handler.js';\n`;
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
|
||||
expect(result.changesCount).toBe(1);
|
||||
expect(sourceFile.getFullText()).not.toContain('@modelcontextprotocol/sdk');
|
||||
expect(result.diagnostics.some(d => d.message.includes('Server auth removed'))).toBe(true);
|
||||
const output = sourceFile.getFullText();
|
||||
expect(output).toContain('@modelcontextprotocol/server-legacy/auth');
|
||||
expect(output).toContain('SomeType');
|
||||
expect(output).not.toContain('@modelcontextprotocol/sdk');
|
||||
expect(result.diagnostics.some(d => d.message.includes('Legacy auth module'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rewrites InMemoryTransport to @modelcontextprotocol/server for server projects', () => {
|
||||
|
|
|
|||
|
|
@ -193,13 +193,22 @@ describe('mock-paths transform', () => {
|
|||
expect(result.diagnostics[0]!.message).toContain('Unknown SDK dynamic import path');
|
||||
});
|
||||
|
||||
it('emits warning for removed SDK dynamic import path', () => {
|
||||
it('rewrites SSE dynamic import to server-legacy/sse', () => {
|
||||
const input = [`const m = await import('@modelcontextprotocol/sdk/server/sse.js');`, ''].join('\n');
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = mockPathsTransform.apply(sourceFile, ctx);
|
||||
expect(result.changesCount).toBeGreaterThan(0);
|
||||
expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/server-legacy/sse');
|
||||
});
|
||||
|
||||
it('emits warning for removed SDK dynamic import path', () => {
|
||||
const input = [`const m = await import('@modelcontextprotocol/sdk/client/websocket.js');`, ''].join('\n');
|
||||
const project = new Project({ useInMemoryFileSystem: true });
|
||||
const sourceFile = project.createSourceFile('test.ts', input);
|
||||
const result = mockPathsTransform.apply(sourceFile, ctx);
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics[0]!.message).toContain('SSE server transport removed in v2');
|
||||
expect(result.diagnostics[0]!.message).toContain('WebSocketClientTransport removed in v2');
|
||||
});
|
||||
|
||||
it('emits warning for unknown SDK mock path', () => {
|
||||
|
|
|
|||
39
packages/server-legacy/README.md
Normal file
39
packages/server-legacy/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# @modelcontextprotocol/server-legacy
|
||||
|
||||
> **Deprecated** — This package is a frozen copy of v1 code for migration purposes only. It will not receive new features and is planned for removal in v3.
|
||||
|
||||
Provides two pieces of v1 server functionality removed in v2:
|
||||
|
||||
- **SSE Transport** (`./sse`) — The `SSEServerTransport` class, replaced in v2 by `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`) or `WebStandardStreamableHTTPServerTransport` (from `@modelcontextprotocol/server`)
|
||||
- **OAuth Authorization Server** (`./auth`) — The `mcpAuthRouter` and related helpers, removed in v2 because MCP servers should use dedicated OAuth providers
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @modelcontextprotocol/server-legacy
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### SSE Transport (no Express dependency required)
|
||||
|
||||
```ts
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse';
|
||||
```
|
||||
|
||||
### OAuth Auth Router (requires Express)
|
||||
|
||||
```ts
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/server-legacy/auth';
|
||||
```
|
||||
|
||||
### Everything (requires Express)
|
||||
|
||||
```ts
|
||||
import { SSEServerTransport, mcpAuthRouter } from '@modelcontextprotocol/server-legacy';
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
- **SSE → StreamableHTTP**: Use `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` (Node.js) or `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` (Web Standard / Cloudflare Workers)
|
||||
- **Auth router → Dedicated IdP**: Use a dedicated OAuth provider (Auth0, Keycloak, etc.) instead of the built-in OAuth Authorization Server
|
||||
12
packages/server-legacy/eslint.config.mjs
Normal file
12
packages/server-legacy/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@modelcontextprotocol/core'
|
||||
}
|
||||
}
|
||||
];
|
||||
104
packages/server-legacy/package.json
Normal file
104
packages/server-legacy/package.json
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server-legacy",
|
||||
"private": false,
|
||||
"version": "2.0.0-alpha.2",
|
||||
"description": "Frozen v1 SSE transport and OAuth Authorization Server helpers for the Model Context Protocol TypeScript SDK. Deprecated; use StreamableHTTP and a dedicated OAuth server in production.",
|
||||
"deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"modelcontextprotocol",
|
||||
"mcp",
|
||||
"server",
|
||||
"legacy",
|
||||
"sse",
|
||||
"oauth"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"./sse": {
|
||||
"types": "./dist/sse/index.d.mts",
|
||||
"import": "./dist/sse/index.mjs"
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./dist/auth/index.d.mts",
|
||||
"import": "./dist/auth/index.mjs"
|
||||
}
|
||||
},
|
||||
"types": "./dist/index.d.mts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"sse": [
|
||||
"dist/sse/index.d.mts"
|
||||
],
|
||||
"auth": [
|
||||
"dist/auth/index.d.mts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -p tsconfig.json --noEmit",
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch",
|
||||
"prepack": "pnpm run build",
|
||||
"lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .",
|
||||
"lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .",
|
||||
"check": "pnpm run typecheck && pnpm run lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:runtimeShared",
|
||||
"raw-body": "catalog:runtimeServerOnly",
|
||||
"content-type": "catalog:runtimeServerOnly",
|
||||
"cors": "catalog:runtimeServerOnly",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"pkce-challenge": "catalog:runtimeShared"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.18.0 || ^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"express": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/core": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@types/content-type": "catalog:devTools",
|
||||
"@types/cors": "catalog:devTools",
|
||||
"@types/express": "catalog:devTools",
|
||||
"@types/express-serve-static-core": "catalog:devTools",
|
||||
"@types/supertest": "catalog:devTools",
|
||||
"@eslint/js": "catalog:devTools",
|
||||
"@typescript/native-preview": "catalog:devTools",
|
||||
"eslint": "catalog:devTools",
|
||||
"eslint-config-prettier": "catalog:devTools",
|
||||
"eslint-plugin-n": "catalog:devTools",
|
||||
"prettier": "catalog:devTools",
|
||||
"supertest": "catalog:devTools",
|
||||
"tsdown": "catalog:devTools",
|
||||
"typescript": "catalog:devTools",
|
||||
"typescript-eslint": "catalog:devTools",
|
||||
"vitest": "catalog:devTools"
|
||||
}
|
||||
}
|
||||
22
packages/server-legacy/src/auth/clients.ts
Normal file
22
packages/server-legacy/src/auth/clients.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
|
||||
|
||||
/**
|
||||
* Stores information about registered OAuth clients for this server.
|
||||
*/
|
||||
export interface OAuthRegisteredClientsStore {
|
||||
/**
|
||||
* Returns information about a registered client, based on its ID.
|
||||
*/
|
||||
getClient(clientId: string): OAuthClientInformationFull | undefined | Promise<OAuthClientInformationFull | undefined>;
|
||||
|
||||
/**
|
||||
* Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
|
||||
*
|
||||
* NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well.
|
||||
*
|
||||
* If unimplemented, dynamic client registration is unsupported.
|
||||
*/
|
||||
registerClient?(
|
||||
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>
|
||||
): OAuthClientInformationFull | Promise<OAuthClientInformationFull>;
|
||||
}
|
||||
212
packages/server-legacy/src/auth/errors.ts
Normal file
212
packages/server-legacy/src/auth/errors.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { OAuthErrorResponse } from '@modelcontextprotocol/core';
|
||||
|
||||
/**
|
||||
* Base class for all OAuth errors
|
||||
*/
|
||||
export class OAuthError extends Error {
|
||||
static errorCode: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorUri?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the error to a standard OAuth error response object
|
||||
*/
|
||||
toResponseObject(): OAuthErrorResponse {
|
||||
const response: OAuthErrorResponse = {
|
||||
error: this.errorCode,
|
||||
error_description: this.message
|
||||
};
|
||||
|
||||
if (this.errorUri) {
|
||||
response.error_uri = this.errorUri;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
get errorCode(): string {
|
||||
return (this.constructor as typeof OAuthError).errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid request error - The request is missing a required parameter,
|
||||
* includes an invalid parameter value, includes a parameter more than once,
|
||||
* or is otherwise malformed.
|
||||
*/
|
||||
export class InvalidRequestError extends OAuthError {
|
||||
static override errorCode = 'invalid_request';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid client error - Client authentication failed (e.g., unknown client, no client
|
||||
* authentication included, or unsupported authentication method).
|
||||
*/
|
||||
export class InvalidClientError extends OAuthError {
|
||||
static override errorCode = 'invalid_client';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid grant error - The provided authorization grant or refresh token is
|
||||
* invalid, expired, revoked, does not match the redirection URI used in the
|
||||
* authorization request, or was issued to another client.
|
||||
*/
|
||||
export class InvalidGrantError extends OAuthError {
|
||||
static override errorCode = 'invalid_grant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized client error - The authenticated client is not authorized to use
|
||||
* this authorization grant type.
|
||||
*/
|
||||
export class UnauthorizedClientError extends OAuthError {
|
||||
static override errorCode = 'unauthorized_client';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsupported grant type error - The authorization grant type is not supported
|
||||
* by the authorization server.
|
||||
*/
|
||||
export class UnsupportedGrantTypeError extends OAuthError {
|
||||
static override errorCode = 'unsupported_grant_type';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid scope error - The requested scope is invalid, unknown, malformed, or
|
||||
* exceeds the scope granted by the resource owner.
|
||||
*/
|
||||
export class InvalidScopeError extends OAuthError {
|
||||
static override errorCode = 'invalid_scope';
|
||||
}
|
||||
|
||||
/**
|
||||
* Access denied error - The resource owner or authorization server denied the request.
|
||||
*/
|
||||
export class AccessDeniedError extends OAuthError {
|
||||
static override errorCode = 'access_denied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Server error - The authorization server encountered an unexpected condition
|
||||
* that prevented it from fulfilling the request.
|
||||
*/
|
||||
export class ServerError extends OAuthError {
|
||||
static override errorCode = 'server_error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily unavailable error - The authorization server is currently unable to
|
||||
* handle the request due to a temporary overloading or maintenance of the server.
|
||||
*/
|
||||
export class TemporarilyUnavailableError extends OAuthError {
|
||||
static override errorCode = 'temporarily_unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsupported response type error - The authorization server does not support
|
||||
* obtaining an authorization code using this method.
|
||||
*/
|
||||
export class UnsupportedResponseTypeError extends OAuthError {
|
||||
static override errorCode = 'unsupported_response_type';
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsupported token type error - The authorization server does not support
|
||||
* the requested token type.
|
||||
*/
|
||||
export class UnsupportedTokenTypeError extends OAuthError {
|
||||
static override errorCode = 'unsupported_token_type';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid token error - The access token provided is expired, revoked, malformed,
|
||||
* or invalid for other reasons.
|
||||
*/
|
||||
export class InvalidTokenError extends OAuthError {
|
||||
static override errorCode = 'invalid_token';
|
||||
}
|
||||
|
||||
/**
|
||||
* Method not allowed error - The HTTP method used is not allowed for this endpoint.
|
||||
* (Custom, non-standard error)
|
||||
*/
|
||||
export class MethodNotAllowedError extends OAuthError {
|
||||
static override errorCode = 'method_not_allowed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Too many requests error - Rate limit exceeded.
|
||||
* (Custom, non-standard error based on RFC 6585)
|
||||
*/
|
||||
export class TooManyRequestsError extends OAuthError {
|
||||
static override errorCode = 'too_many_requests';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid client metadata error - The client metadata is invalid.
|
||||
* (Custom error for dynamic client registration - RFC 7591)
|
||||
*/
|
||||
export class InvalidClientMetadataError extends OAuthError {
|
||||
static override errorCode = 'invalid_client_metadata';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insufficient scope error - The request requires higher privileges than provided by the access token.
|
||||
*/
|
||||
export class InsufficientScopeError extends OAuthError {
|
||||
static override errorCode = 'insufficient_scope';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid target error - The requested resource is invalid, missing, unknown, or malformed.
|
||||
* (Custom error for resource indicators - RFC 8707)
|
||||
*/
|
||||
export class InvalidTargetError extends OAuthError {
|
||||
static override errorCode = 'invalid_target';
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility class for defining one-off error codes
|
||||
*/
|
||||
export class CustomOAuthError extends OAuthError {
|
||||
constructor(
|
||||
private readonly customErrorCode: string,
|
||||
message: string,
|
||||
errorUri?: string
|
||||
) {
|
||||
super(message, errorUri);
|
||||
}
|
||||
|
||||
override get errorCode(): string {
|
||||
return this.customErrorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A full list of all OAuthErrors, enabling parsing from error responses
|
||||
*/
|
||||
export const OAUTH_ERRORS = {
|
||||
[InvalidRequestError.errorCode]: InvalidRequestError,
|
||||
[InvalidClientError.errorCode]: InvalidClientError,
|
||||
[InvalidGrantError.errorCode]: InvalidGrantError,
|
||||
[UnauthorizedClientError.errorCode]: UnauthorizedClientError,
|
||||
[UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError,
|
||||
[InvalidScopeError.errorCode]: InvalidScopeError,
|
||||
[AccessDeniedError.errorCode]: AccessDeniedError,
|
||||
[ServerError.errorCode]: ServerError,
|
||||
[TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError,
|
||||
[UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError,
|
||||
[UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError,
|
||||
[InvalidTokenError.errorCode]: InvalidTokenError,
|
||||
[MethodNotAllowedError.errorCode]: MethodNotAllowedError,
|
||||
[TooManyRequestsError.errorCode]: TooManyRequestsError,
|
||||
[InvalidClientMetadataError.errorCode]: InvalidClientMetadataError,
|
||||
[InsufficientScopeError.errorCode]: InsufficientScopeError,
|
||||
[InvalidTargetError.errorCode]: InvalidTargetError
|
||||
} as const;
|
||||
201
packages/server-legacy/src/auth/handlers/authorize.ts
Normal file
201
packages/server-legacy/src/auth/handlers/authorize.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type AuthorizationHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the authorization endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
/**
|
||||
* Validates a requested redirect_uri against a registered one.
|
||||
*
|
||||
* Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST
|
||||
* allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to
|
||||
* accommodate native clients that obtain an ephemeral port from the OS. For
|
||||
* non-loopback URIs, exact match is required.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||
*/
|
||||
export function redirectUriMatches(requested: string, registered: string): boolean {
|
||||
if (requested === registered) {
|
||||
return true;
|
||||
}
|
||||
let req: URL, reg: URL;
|
||||
try {
|
||||
req = new URL(requested);
|
||||
reg = new URL(registered);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Port relaxation only applies when both URIs target a loopback host.
|
||||
if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) {
|
||||
return false;
|
||||
}
|
||||
// RFC 8252 relaxes the port only — scheme, host, path, and query must
|
||||
// still match exactly. Note: hostname must match exactly too (the RFC
|
||||
// does not allow localhost↔127.0.0.1 cross-matching).
|
||||
return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search;
|
||||
}
|
||||
|
||||
// Parameters that must be validated in order to issue redirects.
|
||||
const ClientAuthorizationParamsSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' })
|
||||
});
|
||||
|
||||
// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
|
||||
const RequestAuthorizationParamsSchema = z.object({
|
||||
response_type: z.literal('code'),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.literal('S256'),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
|
||||
// Create a router to apply middleware
|
||||
const router = express.Router();
|
||||
router.use(allowedMethods(['GET', 'POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // 100 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
router.all('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
// In the authorization flow, errors are split into two categories:
|
||||
// 1. Pre-redirect errors (direct response with 400)
|
||||
// 2. Post-redirect errors (redirect with error parameters)
|
||||
|
||||
// Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses.
|
||||
let client_id, redirect_uri, client;
|
||||
try {
|
||||
const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
|
||||
if (!result.success) {
|
||||
throw new InvalidRequestError(result.error.message);
|
||||
}
|
||||
|
||||
client_id = result.data.client_id;
|
||||
redirect_uri = result.data.redirect_uri;
|
||||
|
||||
client = await provider.clientsStore.getClient(client_id);
|
||||
if (!client) {
|
||||
throw new InvalidClientError('Invalid client_id');
|
||||
}
|
||||
|
||||
if (redirect_uri !== undefined) {
|
||||
const requested = redirect_uri;
|
||||
if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) {
|
||||
throw new InvalidRequestError('Unregistered redirect_uri');
|
||||
}
|
||||
} else if (client.redirect_uris.length === 1) {
|
||||
redirect_uri = client.redirect_uris[0];
|
||||
} else {
|
||||
throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs');
|
||||
}
|
||||
} catch (error) {
|
||||
// Pre-redirect errors - return direct response
|
||||
//
|
||||
// These don't need to be JSON encoded, as they'll be displayed in a user
|
||||
// agent, but OTOH they all represent exceptional situations (arguably,
|
||||
// "programmer error"), so presenting a nice HTML page doesn't help the
|
||||
// user anyway.
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Validate other parameters. Any errors here should go into redirect responses.
|
||||
let state;
|
||||
try {
|
||||
// Parse and validate authorization parameters
|
||||
const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { scope, code_challenge, resource } = parseResult.data;
|
||||
state = parseResult.data.state;
|
||||
|
||||
// Validate scopes
|
||||
let requestedScopes: string[] = [];
|
||||
if (scope !== undefined) {
|
||||
requestedScopes = scope.split(' ');
|
||||
}
|
||||
|
||||
// All validation passed, proceed with authorization
|
||||
await provider.authorize(
|
||||
client,
|
||||
{
|
||||
state,
|
||||
scopes: requestedScopes,
|
||||
redirectUri: redirect_uri!,
|
||||
codeChallenge: code_challenge,
|
||||
resource: resource ? new URL(resource) : undefined
|
||||
},
|
||||
res
|
||||
);
|
||||
} catch (error) {
|
||||
// Post-redirect errors - redirect with error parameters
|
||||
if (error instanceof OAuthError) {
|
||||
res.redirect(302, createErrorRedirect(redirect_uri!, error, state));
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create redirect URL with error parameters
|
||||
*/
|
||||
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
|
||||
const errorUrl = new URL(redirectUri);
|
||||
errorUrl.searchParams.set('error', error.errorCode);
|
||||
errorUrl.searchParams.set('error_description', error.message);
|
||||
if (error.errorUri) {
|
||||
errorUrl.searchParams.set('error_uri', error.errorUri);
|
||||
}
|
||||
if (state) {
|
||||
errorUrl.searchParams.set('state', state);
|
||||
}
|
||||
return errorUrl.href;
|
||||
}
|
||||
21
packages/server-legacy/src/auth/handlers/metadata.ts
Normal file
21
packages/server-legacy/src/auth/handlers/metadata.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
|
||||
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['GET', 'OPTIONS']));
|
||||
router.get('/', (req, res) => {
|
||||
res.status(200).json(metadata);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
124
packages/server-legacy/src/auth/handlers/register.ts
Normal file
124
packages/server-legacy/src/auth/handlers/register.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
|
||||
import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
import type { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { InvalidClientMetadataError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
|
||||
export type ClientRegistrationHandlerOptions = {
|
||||
/**
|
||||
* A store used to save information about dynamically registered OAuth clients.
|
||||
*/
|
||||
clientsStore: OAuthRegisteredClientsStore;
|
||||
|
||||
/**
|
||||
* The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
|
||||
*
|
||||
* If not set, defaults to 30 days.
|
||||
*/
|
||||
clientSecretExpirySeconds?: number;
|
||||
|
||||
/**
|
||||
* Rate limiting configuration for the client registration endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
* Registration endpoints are particularly sensitive to abuse and should be rate limited.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
|
||||
/**
|
||||
* Whether to generate a client ID before calling the client registration endpoint.
|
||||
*
|
||||
* If not set, defaults to true.
|
||||
*/
|
||||
clientIdGeneration?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
export function clientRegistrationHandler({
|
||||
clientsStore,
|
||||
clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS,
|
||||
rateLimit: rateLimitConfig,
|
||||
clientIdGeneration = true
|
||||
}: ClientRegistrationHandlerOptions): RequestHandler {
|
||||
if (!clientsStore.registerClient) {
|
||||
throw new Error('Client registration store does not support registering clients');
|
||||
}
|
||||
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.json());
|
||||
|
||||
// Apply rate limiting unless explicitly disabled - stricter limits for registration
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 20, // 20 requests per hour - stricter as registration is sensitive
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = OAuthClientMetadataSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidClientMetadataError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const clientMetadata = parseResult.data;
|
||||
const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none';
|
||||
|
||||
// Generate client credentials
|
||||
const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex');
|
||||
const clientIdIssuedAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Calculate client secret expiry time
|
||||
const clientsDoExpire = clientSecretExpirySeconds > 0;
|
||||
const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0;
|
||||
const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime;
|
||||
|
||||
let clientInfo: Omit<OAuthClientInformationFull, 'client_id'> & { client_id?: string } = {
|
||||
...clientMetadata,
|
||||
client_secret: clientSecret,
|
||||
client_secret_expires_at: clientSecretExpiresAt
|
||||
};
|
||||
|
||||
if (clientIdGeneration) {
|
||||
clientInfo.client_id = crypto.randomUUID();
|
||||
clientInfo.client_id_issued_at = clientIdIssuedAt;
|
||||
}
|
||||
|
||||
clientInfo = await clientsStore.registerClient!(clientInfo);
|
||||
res.status(201).json(clientInfo);
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
82
packages/server-legacy/src/auth/handlers/revoke.ts
Normal file
82
packages/server-legacy/src/auth/handlers/revoke.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
import { InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import { authenticateClient } from '../middleware/clientAuth.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type RevocationHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the token revocation endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
|
||||
if (!provider.revokeToken) {
|
||||
throw new Error('Auth provider does not support revoking tokens');
|
||||
}
|
||||
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
throw new ServerError('Internal Server Error');
|
||||
}
|
||||
|
||||
await provider.revokeToken!(client, parseResult.data);
|
||||
res.status(200).json({});
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
157
packages/server-legacy/src/auth/handlers/token.ts
Normal file
157
packages/server-legacy/src/auth/handlers/token.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { verifyChallenge } from 'pkce-challenge';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import {
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
OAuthError,
|
||||
ServerError,
|
||||
TooManyRequestsError,
|
||||
UnsupportedGrantTypeError
|
||||
} from '../errors.js';
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import { authenticateClient } from '../middleware/clientAuth.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type TokenHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the token endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
const TokenRequestSchema = z.object({
|
||||
grant_type: z.string()
|
||||
});
|
||||
|
||||
const AuthorizationCodeGrantSchema = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string(),
|
||||
redirect_uri: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
const RefreshTokenGrantSchema = z.object({
|
||||
refresh_token: z.string(),
|
||||
scope: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = TokenRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { grant_type } = parseResult.data;
|
||||
|
||||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
throw new ServerError('Internal Server Error');
|
||||
}
|
||||
|
||||
switch (grant_type) {
|
||||
case 'authorization_code': {
|
||||
const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
|
||||
|
||||
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
|
||||
|
||||
// Perform local PKCE validation unless explicitly skipped
|
||||
// (e.g. to validate code_verifier in upstream server)
|
||||
if (!skipLocalPkceValidation) {
|
||||
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
|
||||
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
|
||||
throw new InvalidGrantError('code_verifier does not match the challenge');
|
||||
}
|
||||
}
|
||||
|
||||
// Passes the code_verifier to the provider if PKCE validation didn't occur locally
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
client,
|
||||
code,
|
||||
skipLocalPkceValidation ? code_verifier : undefined,
|
||||
redirect_uri,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'refresh_token': {
|
||||
const parseResult = RefreshTokenGrantSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { refresh_token, scope, resource } = parseResult.data;
|
||||
|
||||
const scopes = scope?.split(' ');
|
||||
const tokens = await provider.exchangeRefreshToken(
|
||||
client,
|
||||
refresh_token,
|
||||
scopes,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
37
packages/server-legacy/src/auth/index.ts
Normal file
37
packages/server-legacy/src/auth/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Core router
|
||||
export type { AuthMetadataOptions, AuthRouterOptions } from './router.js';
|
||||
export { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, mcpAuthRouter } from './router.js';
|
||||
|
||||
// Provider interfaces
|
||||
export type { AuthorizationParams, OAuthServerProvider, OAuthTokenVerifier } from './provider.js';
|
||||
|
||||
// Proxy provider
|
||||
export type { ProxyEndpoints, ProxyOptions } from './providers/proxyProvider.js';
|
||||
export { ProxyOAuthServerProvider } from './providers/proxyProvider.js';
|
||||
|
||||
// Client store
|
||||
export type { OAuthRegisteredClientsStore } from './clients.js';
|
||||
|
||||
// Handlers
|
||||
export type { AuthorizationHandlerOptions } from './handlers/authorize.js';
|
||||
export { authorizationHandler, redirectUriMatches } from './handlers/authorize.js';
|
||||
export { metadataHandler } from './handlers/metadata.js';
|
||||
export type { ClientRegistrationHandlerOptions } from './handlers/register.js';
|
||||
export { clientRegistrationHandler } from './handlers/register.js';
|
||||
export type { RevocationHandlerOptions } from './handlers/revoke.js';
|
||||
export { revocationHandler } from './handlers/revoke.js';
|
||||
export type { TokenHandlerOptions } from './handlers/token.js';
|
||||
export { tokenHandler } from './handlers/token.js';
|
||||
|
||||
// Middleware
|
||||
export { allowedMethods } from './middleware/allowedMethods.js';
|
||||
export type { BearerAuthMiddlewareOptions } from './middleware/bearerAuth.js';
|
||||
export { requireBearerAuth } from './middleware/bearerAuth.js';
|
||||
export type { ClientAuthenticationMiddlewareOptions } from './middleware/clientAuth.js';
|
||||
export { authenticateClient } from './middleware/clientAuth.js';
|
||||
|
||||
// Error classes
|
||||
export * from './errors.js';
|
||||
|
||||
// Types
|
||||
export type { AuthInfo } from './types.js';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue