Merge branch 'main' into ihrpr/elicitation
This commit is contained in:
commit
eed97c9d17
12 changed files with 812 additions and 209 deletions
|
|
@ -548,7 +548,7 @@ putMessageTool.disable()
|
|||
|
||||
const upgradeAuthTool = server.tool(
|
||||
"upgradeAuth",
|
||||
{ permission: z.enum(["write', vadmin"])},
|
||||
{ permission: z.enum(["write', admin"])},
|
||||
// Any mutations here will automatically emit `listChanged` notifications
|
||||
async ({ permission }) => {
|
||||
const { ok, err, previous } = await upgradeAuthAndStoreToken(permission)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/sdk",
|
||||
"version": "1.11.4",
|
||||
"version": "1.12.2",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
|
|||
|
|
@ -366,6 +366,31 @@ describe("OAuth Authorization", () => {
|
|||
expect(authorizationUrl.searchParams.has("scope")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes state parameter when provided", async () => {
|
||||
const { authorizationUrl } = await startAuthorization(
|
||||
"https://auth.example.com",
|
||||
{
|
||||
clientInformation: validClientInfo,
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
state: "foobar",
|
||||
}
|
||||
);
|
||||
|
||||
expect(authorizationUrl.searchParams.get("state")).toBe("foobar");
|
||||
});
|
||||
|
||||
it("excludes state parameter when not provided", async () => {
|
||||
const { authorizationUrl } = await startAuthorization(
|
||||
"https://auth.example.com",
|
||||
{
|
||||
clientInformation: validClientInfo,
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
}
|
||||
);
|
||||
|
||||
expect(authorizationUrl.searchParams.has("state")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses metadata authorization_endpoint when provided", async () => {
|
||||
const { authorizationUrl } = await startAuthorization(
|
||||
"https://auth.example.com",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ export interface OAuthClientProvider {
|
|||
*/
|
||||
get clientMetadata(): OAuthClientMetadata;
|
||||
|
||||
/**
|
||||
* Returns a OAuth2 state parameter.
|
||||
*/
|
||||
state?(): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Loads information about this OAuth client, as registered already with the
|
||||
* server, or returns `undefined` if the client is not registered with the
|
||||
|
|
@ -162,10 +167,13 @@ export async function auth(
|
|||
}
|
||||
}
|
||||
|
||||
const state = provider.state ? await provider.state() : undefined;
|
||||
|
||||
// Start new authorization flow
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation,
|
||||
state,
|
||||
redirectUrl: provider.redirectUrl,
|
||||
scope: scope || provider.clientMetadata.scope,
|
||||
});
|
||||
|
|
@ -301,11 +309,13 @@ export async function startAuthorization(
|
|||
clientInformation,
|
||||
redirectUrl,
|
||||
scope,
|
||||
state,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
redirectUrl: string | URL;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
},
|
||||
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
|
||||
const responseType = "code";
|
||||
|
|
@ -347,6 +357,10 @@ export async function startAuthorization(
|
|||
);
|
||||
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
|
||||
|
||||
if (state) {
|
||||
authorizationUrl.searchParams.set("state", state);
|
||||
}
|
||||
|
||||
if (scope) {
|
||||
authorizationUrl.searchParams.set("scope", scope);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ export class SSEClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private async _commonHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {};
|
||||
const headers: HeadersInit = { ...this._requestInit?.headers };
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens();
|
||||
if (tokens) {
|
||||
headers["Authorization"] = `Bearer ${tokens.access_token}`;
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${tokens.access_token}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ A full-featured interactive client that connects to a Streamable HTTP server, de
|
|||
npx tsx src/examples/client/simpleStreamableHttp.ts
|
||||
```
|
||||
|
||||
Example client with OAuth:
|
||||
|
||||
```bash
|
||||
npx tsx src/examples/client/simpleOAuthClient.js
|
||||
```
|
||||
|
||||
### Backwards Compatible Client
|
||||
|
||||
A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates:
|
||||
|
|
|
|||
422
src/examples/client/simpleOAuthClient.ts
Normal file
422
src/examples/client/simpleOAuthClient.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { URL } from 'node:url';
|
||||
import { exec } from 'node:child_process';
|
||||
import { Client } from '../../client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
|
||||
import { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js';
|
||||
import {
|
||||
CallToolRequest,
|
||||
ListToolsRequest,
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema
|
||||
} from '../../types.js';
|
||||
import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js';
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
|
||||
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
|
||||
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
|
||||
|
||||
/**
|
||||
* In-memory OAuth client provider for demonstration purposes
|
||||
* In production, you should persist tokens securely
|
||||
*/
|
||||
class InMemoryOAuthClientProvider implements OAuthClientProvider {
|
||||
private _clientInformation?: OAuthClientInformationFull;
|
||||
private _tokens?: OAuthTokens;
|
||||
private _codeVerifier?: string;
|
||||
|
||||
constructor(
|
||||
private readonly _redirectUrl: string | URL,
|
||||
private readonly _clientMetadata: OAuthClientMetadata,
|
||||
onRedirect?: (url: URL) => void
|
||||
) {
|
||||
this._onRedirect = onRedirect || ((url) => {
|
||||
console.log(`Redirect to: ${url.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
private _onRedirect: (url: URL) => void;
|
||||
|
||||
get redirectUrl(): string | URL {
|
||||
return this._redirectUrl;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
return this._clientInformation;
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
|
||||
this._clientInformation = clientInformation;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL): void {
|
||||
this._onRedirect(authorizationUrl);
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string): void {
|
||||
this._codeVerifier = codeVerifier;
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this._codeVerifier) {
|
||||
throw new Error('No code verifier saved');
|
||||
}
|
||||
return this._codeVerifier;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Interactive MCP client with OAuth authentication
|
||||
* Demonstrates the complete OAuth flow with browser-based authorization
|
||||
*/
|
||||
class InteractiveOAuthClient {
|
||||
private client: Client | null = null;
|
||||
private readonly rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
constructor(private serverUrl: string) { }
|
||||
|
||||
/**
|
||||
* Prompts user for input via readline
|
||||
*/
|
||||
private async question(query: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
this.rl.question(query, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the authorization URL in the user's default browser
|
||||
*/
|
||||
private async openBrowser(url: string): Promise<void> {
|
||||
console.log(`🌐 Opening browser for authorization: ${url}`);
|
||||
|
||||
const command = `open "${url}"`;
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
console.error(`Failed to open browser: ${error.message}`);
|
||||
console.log(`Please manually open: ${url}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Example OAuth callback handler - in production, use a more robust approach
|
||||
* for handling callbacks and storing tokens
|
||||
*/
|
||||
/**
|
||||
* Starts a temporary HTTP server to receive the OAuth callback
|
||||
*/
|
||||
private async waitForOAuthCallback(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
// Ignore favicon requests
|
||||
if (req.url === '/favicon.ico') {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📥 Received callback: ${req.url}`);
|
||||
const parsedUrl = new URL(req.url || '', 'http://localhost');
|
||||
const code = parsedUrl.searchParams.get('code');
|
||||
const error = parsedUrl.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
resolve(code);
|
||||
setTimeout(() => server.close(), 3000);
|
||||
} else if (error) {
|
||||
console.log(`❌ Authorization error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
reject(new Error(`OAuth authorization failed: ${error}`));
|
||||
} else {
|
||||
console.log(`❌ No authorization code or error in callback`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
reject(new Error('No authorization code provided'));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, () => {
|
||||
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
|
||||
console.log('🚢 Creating transport with OAuth provider...');
|
||||
const baseUrl = new URL(this.serverUrl);
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl, {
|
||||
authProvider: oauthProvider
|
||||
});
|
||||
console.log('🚢 Transport created');
|
||||
|
||||
try {
|
||||
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
|
||||
await this.client!.connect(transport);
|
||||
console.log('✅ Connected successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log('🔐 OAuth required - waiting for authorization...');
|
||||
const callbackPromise = this.waitForOAuthCallback();
|
||||
const authCode = await callbackPromise;
|
||||
await transport.finishAuth(authCode);
|
||||
console.log('🔐 Authorization code received:', authCode);
|
||||
console.log('🔌 Reconnecting with authenticated transport...');
|
||||
await this.attemptConnection(oauthProvider);
|
||||
} else {
|
||||
console.error('❌ Connection failed with non-auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes connection to the MCP server with OAuth authentication
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
|
||||
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'Simple OAuth MCP Client',
|
||||
redirect_uris: [CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
scope: 'mcp:tools'
|
||||
};
|
||||
|
||||
console.log('🔐 Creating OAuth provider...');
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(
|
||||
CALLBACK_URL,
|
||||
clientMetadata,
|
||||
(redirectUrl: URL) => {
|
||||
console.log(`📌 OAuth redirect handler called - opening browser`);
|
||||
console.log(`Opening browser to: ${redirectUrl.toString()}`);
|
||||
this.openBrowser(redirectUrl.toString());
|
||||
}
|
||||
);
|
||||
console.log('🔐 OAuth provider created');
|
||||
|
||||
console.log('👤 Creating MCP client...');
|
||||
this.client = new Client({
|
||||
name: 'simple-oauth-client',
|
||||
version: '1.0.0',
|
||||
}, { capabilities: {} });
|
||||
console.log('👤 Client created');
|
||||
|
||||
console.log('🔐 Starting OAuth flow...');
|
||||
|
||||
await this.attemptConnection(oauthProvider);
|
||||
|
||||
// Start interactive loop
|
||||
await this.interactiveLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main interactive loop for user commands
|
||||
*/
|
||||
async interactiveLoop(): Promise<void> {
|
||||
console.log('\n🎯 Interactive MCP Client with OAuth');
|
||||
console.log('Commands:');
|
||||
console.log(' list - List available tools');
|
||||
console.log(' call <tool_name> [args] - Call a tool');
|
||||
console.log(' quit - Exit the client');
|
||||
console.log();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const command = await this.question('mcp> ');
|
||||
|
||||
if (!command.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === 'quit') {
|
||||
break;
|
||||
} else if (command === 'list') {
|
||||
await this.listTools();
|
||||
} else if (command.startsWith('call ')) {
|
||||
await this.handleCallTool(command);
|
||||
} else {
|
||||
console.log('❌ Unknown command. Try \'list\', \'call <tool_name>\', or \'quit\'');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SIGINT') {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
break;
|
||||
}
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listTools(): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
const result = await this.client.request(request, ListToolsResultSchema);
|
||||
|
||||
if (result.tools && result.tools.length > 0) {
|
||||
console.log('\n📋 Available tools:');
|
||||
result.tools.forEach((tool, index) => {
|
||||
console.log(`${index + 1}. ${tool.name}`);
|
||||
if (tool.description) {
|
||||
console.log(` Description: ${tool.description}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
} else {
|
||||
console.log('No tools available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to list tools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCallTool(command: string): Promise<void> {
|
||||
const parts = command.split(/\s+/);
|
||||
const toolName = parts[1];
|
||||
|
||||
if (!toolName) {
|
||||
console.log('❌ Please specify a tool name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse arguments (simple JSON-like format)
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (parts.length > 2) {
|
||||
const argsString = parts.slice(2).join(' ');
|
||||
try {
|
||||
toolArgs = JSON.parse(argsString);
|
||||
} catch {
|
||||
console.log('❌ Invalid arguments format (expected JSON)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.callTool(toolName, toolArgs);
|
||||
}
|
||||
|
||||
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this.client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log(`\n🔧 Tool '${toolName}' result:`);
|
||||
if (result.content) {
|
||||
result.content.forEach((content) => {
|
||||
if (content.type === 'text') {
|
||||
console.log(content.text);
|
||||
} else {
|
||||
console.log(content);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to call tool '${toolName}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.rl.close();
|
||||
if (this.client) {
|
||||
// Note: Client doesn't have a close method in the current implementation
|
||||
// This would typically close the transport connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
|
||||
|
||||
console.log('🚀 Simple MCP OAuth Client');
|
||||
console.log(`Connecting to: ${serverUrl}`);
|
||||
console.log();
|
||||
|
||||
const client = new InteractiveOAuthClient(serverUrl);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
client.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
console.error('Failed to start client:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if this file is executed directly
|
||||
main().catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -58,6 +58,7 @@ function printHelp(): void {
|
|||
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
|
||||
console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)');
|
||||
console.log(' start-notifications [interval] [count] - Start periodic notifications');
|
||||
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
|
||||
console.log(' list-prompts - List available prompts');
|
||||
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
|
||||
console.log(' list-resources - List available resources');
|
||||
|
|
@ -128,6 +129,13 @@ function commandLoop(): void {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'run-notifications-tool-with-resumability': {
|
||||
const interval = args[1] ? parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? parseInt(args[2], 10) : 10;
|
||||
await runNotificationsToolWithResumability(interval, count);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-prompts':
|
||||
await listPrompts();
|
||||
break;
|
||||
|
|
@ -537,12 +545,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
|
|||
};
|
||||
|
||||
console.log(`Calling tool '${name}' with args:`, args);
|
||||
const onLastEventIdUpdate = (event: string) => {
|
||||
notificationsToolLastEventId = event;
|
||||
};
|
||||
const result = await client.request(request, CallToolResultSchema, {
|
||||
resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate
|
||||
});
|
||||
const result = await client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log('Tool result:');
|
||||
result.content.forEach(item => {
|
||||
|
|
@ -557,6 +560,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
async function callGreetTool(name: string): Promise<void> {
|
||||
await callTool('greet', { name });
|
||||
}
|
||||
|
|
@ -576,6 +580,47 @@ async function startNotifications(interval: number, count: number): Promise<void
|
|||
await callTool('start-notification-stream', { interval, count });
|
||||
}
|
||||
|
||||
async function runNotificationsToolWithResumability(interval: number, count: number): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
|
||||
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'start-notification-stream',
|
||||
arguments: { interval, count }
|
||||
}
|
||||
};
|
||||
|
||||
const onLastEventIdUpdate = (event: string) => {
|
||||
notificationsToolLastEventId = event;
|
||||
console.log(`Updated resumption token: ${event}`);
|
||||
};
|
||||
|
||||
const result = await client.request(request, CallToolResultSchema, {
|
||||
resumptionToken: notificationsToolLastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
});
|
||||
|
||||
console.log('Tool result:');
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error starting notification stream: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listPrompts(): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
|
|
|
|||
|
|
@ -913,18 +913,10 @@ describe("tool()", () => {
|
|||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.tool(
|
||||
"test",
|
||||
|
|
@ -1056,17 +1048,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
// Register a tool with outputSchema
|
||||
mcpServer.registerTool(
|
||||
|
|
@ -1169,17 +1154,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
// Register a tool with outputSchema that returns only content without structuredContent
|
||||
mcpServer.registerTool(
|
||||
|
|
@ -1233,17 +1211,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
// Register a tool with outputSchema that returns invalid data
|
||||
mcpServer.registerTool(
|
||||
|
|
@ -1308,17 +1279,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
let receivedSessionId: string | undefined;
|
||||
mcpServer.tool("test-tool", async (extra) => {
|
||||
|
|
@ -1364,17 +1328,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
let receivedRequestId: string | number | undefined;
|
||||
mcpServer.tool("request-id-test", async (extra) => {
|
||||
|
|
@ -1423,17 +1380,10 @@ describe("tool()", () => {
|
|||
{ capabilities: { logging: {} } },
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
let receivedLogMessage: string | undefined;
|
||||
const loggingMessage = "hello here is log message 1";
|
||||
|
|
@ -1480,17 +1430,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.tool(
|
||||
"test",
|
||||
|
|
@ -1546,17 +1489,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.tool("error-test", async () => {
|
||||
throw new Error("Tool execution failed");
|
||||
|
|
@ -1598,17 +1534,10 @@ describe("tool()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.tool("test-tool", async () => ({
|
||||
content: [
|
||||
|
|
@ -2392,6 +2321,48 @@ describe("resource()", () => {
|
|||
).rejects.toThrow(/Resource test:\/\/nonexistent not found/);
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion
|
||||
*/
|
||||
test("should advertise support for completion when a resource template with a complete callback is defined", async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.resource(
|
||||
"test",
|
||||
new ResourceTemplate("test://resource/{category}", {
|
||||
list: undefined,
|
||||
complete: {
|
||||
category: () => ["books", "movies", "music"],
|
||||
},
|
||||
}),
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: "test://resource/test",
|
||||
text: "Test content",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(client.getServerCapabilities()).toMatchObject({ completions: {} })
|
||||
})
|
||||
|
||||
/***
|
||||
* Test: Resource Template Parameter Completion
|
||||
*/
|
||||
|
|
@ -2401,17 +2372,10 @@ describe("resource()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.resource(
|
||||
"test",
|
||||
|
|
@ -2469,17 +2433,10 @@ describe("resource()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.resource(
|
||||
"test",
|
||||
|
|
@ -2540,17 +2497,10 @@ describe("resource()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
let receivedRequestId: string | number | undefined;
|
||||
mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => {
|
||||
|
|
@ -3052,17 +3002,10 @@ describe("prompt()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.prompt(
|
||||
"test",
|
||||
|
|
@ -3258,17 +3201,10 @@ describe("prompt()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.prompt("test-prompt", async () => ({
|
||||
messages: [
|
||||
|
|
@ -3303,6 +3239,49 @@ describe("prompt()", () => {
|
|||
).rejects.toThrow(/Prompt nonexistent-prompt not found/);
|
||||
});
|
||||
|
||||
|
||||
/***
|
||||
* Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion
|
||||
*/
|
||||
test("should advertise support for completion when a prompt with a completable argument is defined", async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.prompt(
|
||||
"test-prompt",
|
||||
{
|
||||
name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]),
|
||||
},
|
||||
async ({ name }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Hello ${name}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(client.getServerCapabilities()).toMatchObject({ completions: {} })
|
||||
})
|
||||
|
||||
/***
|
||||
* Test: Prompt Argument Completion
|
||||
*/
|
||||
|
|
@ -3312,17 +3291,10 @@ describe("prompt()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.prompt(
|
||||
"test-prompt",
|
||||
|
|
@ -3380,17 +3352,10 @@ describe("prompt()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.prompt(
|
||||
"test-prompt",
|
||||
|
|
@ -3450,17 +3415,10 @@ describe("prompt()", () => {
|
|||
version: "1.0",
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
let receivedRequestId: string | number | undefined;
|
||||
mcpServer.prompt("request-id-test", async (extra) => {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,10 @@ export class McpServer {
|
|||
CompleteRequestSchema.shape.method.value,
|
||||
);
|
||||
|
||||
this.server.registerCapabilities({
|
||||
completions: {},
|
||||
});
|
||||
|
||||
this.server.setRequestHandler(
|
||||
CompleteRequestSchema,
|
||||
async (request): Promise<CompleteResult> => {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ class MockTransport implements Transport {
|
|||
describe("protocol tests", () => {
|
||||
let protocol: Protocol<Request, Notification, Result>;
|
||||
let transport: MockTransport;
|
||||
let sendSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
transport = new MockTransport();
|
||||
sendSpy = jest.spyOn(transport, 'send');
|
||||
protocol = new (class extends Protocol<Request, Notification, Result> {
|
||||
protected assertCapabilityForMethod(): void {}
|
||||
protected assertNotificationCapability(): void {}
|
||||
|
|
@ -63,6 +65,130 @@ describe("protocol tests", () => {
|
|||
expect(oncloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("_meta preservation with onprogress", () => {
|
||||
test("should preserve existing _meta when adding progressToken", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue",
|
||||
anotherField: 123
|
||||
}
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue",
|
||||
anotherField: 123,
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should create _meta with progressToken when no _meta exists", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test"
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should not modify _meta when onprogress is not provided", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue"
|
||||
}
|
||||
}
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
|
||||
protocol.request(request, mockSchema);
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
data: "test",
|
||||
_meta: {
|
||||
customField: "customValue"
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
test("should handle params being undefined with onprogress", async () => {
|
||||
await protocol.connect(transport);
|
||||
const request = {
|
||||
method: "example"
|
||||
};
|
||||
const mockSchema: ZodType<{ result: string }> = z.object({
|
||||
result: z.string(),
|
||||
});
|
||||
const onProgressMock = jest.fn();
|
||||
|
||||
protocol.request(request, mockSchema, {
|
||||
onprogress: onProgressMock,
|
||||
});
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: "example",
|
||||
params: {
|
||||
_meta: {
|
||||
progressToken: expect.any(Number)
|
||||
}
|
||||
},
|
||||
jsonrpc: "2.0",
|
||||
id: expect.any(Number)
|
||||
}), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress notification timeout behavior", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -541,7 +541,10 @@ export abstract class Protocol<
|
|||
this._progressHandlers.set(messageId, options.onprogress);
|
||||
jsonrpcRequest.params = {
|
||||
...request.params,
|
||||
_meta: { progressToken: messageId },
|
||||
_meta: {
|
||||
...(request.params?._meta || {}),
|
||||
progressToken: messageId
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue