feat: add support for MCP Sampling as AI provider (#863)

* feat: support MCP sampling

* support provider registry

* use standard config options for MCP provider

* update fastmcp to support passing params to requestSampling

* move key name definition to base provider

* moved check for required api key to provider class

* remove unused code

* more cleanup

* more cleanup

* refactor provider

* remove not needed files

* more cleanup

* more cleanup

* more cleanup

* update docs

* fix tests

* add tests

* format fix

* clean files

* merge fixes

* format fix

* feat: add support for MCP Sampling as AI provider

* initial mcp ai sdk

* fix references to old provider

* update models

* lint

* fix gemini-cli conflicts

* ran format

* Update src/provider-registry/index.js

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>

* fix circular dependency

Circular Dependency Issue  FIXED
Root Cause: BaseAIProvider was importing from index.js, which includes commands.js and other modules that eventually import back to AI providers
Solution: Changed imports to use direct paths to avoid circular dependencies:
Updated base-provider.js to import log directly from utils.js
Updated gemini-cli.js to import log directly from utils.js
Result: Fixed 11 failing tests in mcp-provider.test.js

* fix gemini test

* fix(claude-code): recover from CLI JSON truncation bug (#913) (#920)

Gracefully handle SyntaxError thrown by @anthropic-ai/claude-code when the CLI truncates large JSON outputs (4–16 kB cut-offs).\n\nKey points:\n• Detect JSON parse error + existing buffered text in both doGenerate() and doStream() code paths.\n• Convert the failure into a recoverable 'truncated' finish state and push a provider-warning.\n• Allows Task Master to continue parsing long PRDs / expand-task operations instead of crashing.\n\nA patch changeset (.changeset/claude-code-json-truncation.md) is included for the next release.\n\nRef: eyaltoledano/claude-task-master#913

* docs: fix gemini-cli authentication documentation (#923)

Remove erroneous 'gemini auth login' command references and replace with correct 'gemini' command authentication flow. Update documentation to reflect proper OAuth setup process via the gemini CLI interactive interface.

* fix tests

* fix: update ai-sdk-provider-gemini-cli to 0.0.4 for improved authentication (#932)

- Fixed authentication compatibility issues with Google auth
- Added support for 'api-key' auth type alongside 'gemini-api-key'
- Resolved "Unsupported authType: undefined" runtime errors
- Updated @google/gemini-cli-core dependency to 0.1.9
- Improved documentation and removed invalid auth references
- Maintained backward compatibility while enhancing type validation

* call logging directly

Need to patch upstream fastmcp to allow easier access and bootstrap the TM mcp logger to use the fastmcp logger which today is only exposed in the tools handler

* fix tests

* removing logs until we figure out how to pass mcp logger

* format

* fix tests

* format

* clean up

* cleanup

* readme fix

---------

Co-authored-by: Oren Melamed <oren.m@gloat.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: Ben Vargas <ben@vargas.com>
This commit is contained in:
Oren Me
2025-07-09 11:54:38 +03:00
committed by GitHub
parent de28026b32
commit b53065713c
40 changed files with 3122 additions and 72 deletions

View File

@@ -0,0 +1,106 @@
/**
* src/ai-providers/custom-sdk/mcp/errors.js
*
* Error handling utilities for MCP AI SDK provider.
* Maps MCP errors to AI SDK compatible error types.
*/
/**
* MCP-specific error class
*/
export class MCPError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'MCPError';
this.code = options.code;
this.cause = options.cause;
this.mcpResponse = options.mcpResponse;
}
}
/**
* Session-related error
*/
export class MCPSessionError extends MCPError {
constructor(message, options = {}) {
super(message, options);
this.name = 'MCPSessionError';
}
}
/**
* Sampling-related error
*/
export class MCPSamplingError extends MCPError {
constructor(message, options = {}) {
super(message, options);
this.name = 'MCPSamplingError';
}
}
/**
* Map MCP errors to AI SDK compatible error types
* @param {Error} error - Original error
* @returns {Error} Mapped error
*/
export function mapMCPError(error) {
// If already an MCP error, return as-is
if (error instanceof MCPError) {
return error;
}
const message = error.message || 'Unknown MCP error';
const originalError = error;
// Map common error patterns
if (message.includes('session') || message.includes('connection')) {
return new MCPSessionError(message, {
cause: originalError,
code: 'SESSION_ERROR'
});
}
if (message.includes('sampling') || message.includes('timeout')) {
return new MCPSamplingError(message, {
cause: originalError,
code: 'SAMPLING_ERROR'
});
}
if (message.includes('capabilities') || message.includes('not supported')) {
return new MCPSessionError(message, {
cause: originalError,
code: 'CAPABILITY_ERROR'
});
}
// Default to generic MCP error
return new MCPError(message, {
cause: originalError,
code: 'UNKNOWN_ERROR'
});
}
/**
* Check if error is retryable
* @param {Error} error - Error to check
* @returns {boolean} True if error might be retryable
*/
export function isRetryableError(error) {
if (error instanceof MCPSamplingError && error.code === 'SAMPLING_ERROR') {
return true;
}
if (error instanceof MCPSessionError && error.code === 'SESSION_ERROR') {
// Session errors are generally not retryable
return false;
}
// Check for common retryable patterns
const message = error.message?.toLowerCase() || '';
return (
message.includes('timeout') ||
message.includes('network') ||
message.includes('temporary')
);
}

View File

@@ -0,0 +1,47 @@
/**
* src/ai-providers/custom-sdk/mcp/index.js
*
* AI SDK factory function for MCP provider.
* Creates MCP language model instances with session-based AI operations.
*/
import { MCPLanguageModel } from './language-model.js';
/**
* Create MCP provider factory function following AI SDK patterns
* @param {object} options - Provider options
* @param {object} options.session - MCP session object
* @param {object} options.defaultSettings - Default settings for the provider
* @returns {Function} Provider factory function
*/
export function createMCP(options = {}) {
if (!options.session) {
throw new Error('MCP provider requires session object');
}
// Return the provider factory function that AI SDK expects
const provider = function (modelId, settings = {}) {
if (new.target) {
throw new Error(
'The MCP model function cannot be called with the new keyword.'
);
}
return new MCPLanguageModel({
session: options.session,
modelId: modelId || 'claude-3-5-sonnet-20241022',
settings: {
temperature: settings.temperature,
maxTokens: settings.maxTokens,
...options.defaultSettings,
...settings
}
});
};
// Add required methods for AI SDK compatibility
provider.languageModel = (modelId, settings) => provider(modelId, settings);
provider.chat = (modelId, settings) => provider(modelId, settings);
return provider;
}

View File

@@ -0,0 +1,109 @@
/**
* @fileoverview Extract JSON from MCP response, handling markdown blocks and other formatting
*/
/**
* Extract JSON from MCP AI response
* @param {string} text - The text to extract JSON from
* @returns {string} - The extracted JSON string
*/
export function extractJson(text) {
// Remove markdown code blocks if present
let jsonText = text.trim();
// Remove ```json blocks
jsonText = jsonText.replace(/^```json\s*/gm, '');
jsonText = jsonText.replace(/^```\s*/gm, '');
jsonText = jsonText.replace(/```\s*$/gm, '');
// Remove common TypeScript/JavaScript patterns
jsonText = jsonText.replace(/^const\s+\w+\s*=\s*/, ''); // Remove "const varName = "
jsonText = jsonText.replace(/^let\s+\w+\s*=\s*/, ''); // Remove "let varName = "
jsonText = jsonText.replace(/^var\s+\w+\s*=\s*/, ''); // Remove "var varName = "
jsonText = jsonText.replace(/;?\s*$/, ''); // Remove trailing semicolons
// Remove explanatory text before JSON (common with AI responses)
jsonText = jsonText.replace(/^.*?(?=\{|\[)/s, '');
// Remove explanatory text after JSON
const lines = jsonText.split('\n');
let jsonEndIndex = -1;
let braceCount = 0;
let inString = false;
let escapeNext = false;
// Find the end of the JSON by tracking braces
for (let i = 0; i < jsonText.length; i++) {
const char = jsonText[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"' && !escapeNext) {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{' || char === '[') {
braceCount++;
} else if (char === '}' || char === ']') {
braceCount--;
if (braceCount === 0) {
jsonEndIndex = i;
break;
}
}
}
}
if (jsonEndIndex > -1) {
jsonText = jsonText.substring(0, jsonEndIndex + 1);
}
// Try to extract JSON object or array if previous method didn't work
if (jsonEndIndex === -1) {
const objectMatch = jsonText.match(/{[\s\S]*}/);
const arrayMatch = jsonText.match(/\[[\s\S]*\]/);
if (objectMatch) {
jsonText = objectMatch[0];
} else if (arrayMatch) {
jsonText = arrayMatch[0];
}
}
// First try to parse as valid JSON
try {
JSON.parse(jsonText);
return jsonText;
} catch {
// If it's not valid JSON, it might be a JavaScript object literal
// Try to convert it to valid JSON
try {
// This is a simple conversion that handles basic cases
// Replace unquoted keys with quoted keys
const converted = jsonText
.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":')
// Replace single quotes with double quotes
.replace(/'/g, '"')
// Handle trailing commas
.replace(/,\s*([}\]])/g, '$1');
// Validate the converted JSON
JSON.parse(converted);
return converted;
} catch {
// If all else fails, return the original text
// The calling code will handle the error appropriately
return text;
}
}
}

View File

@@ -0,0 +1,230 @@
/**
* src/ai-providers/custom-sdk/mcp/language-model.js
*
* MCP Language Model implementation following AI SDK LanguageModelV1 interface.
* Uses MCP session.requestSampling() for AI operations.
*/
import {
convertToMCPFormat,
convertFromMCPFormat
} from './message-converter.js';
import { MCPError, mapMCPError } from './errors.js';
import { extractJson } from './json-extractor.js';
import {
convertSchemaToInstructions,
enhancePromptForJSON
} from './schema-converter.js';
/**
* MCP Language Model implementing AI SDK LanguageModelV1 interface
*/
export class MCPLanguageModel {
specificationVersion = 'v1';
defaultObjectGenerationMode = 'json';
supportsImageUrls = false;
supportsStructuredOutputs = true;
constructor(options) {
this.session = options.session; // MCP session object
this.modelId = options.modelId;
this.settings = options.settings || {};
this.provider = 'mcp-ai-sdk';
this.maxTokens = this.settings.maxTokens;
this.temperature = this.settings.temperature;
this.validateSession();
}
/**
* Validate that the MCP session has required capabilities
*/
validateSession() {
if (!this.session?.clientCapabilities?.sampling) {
throw new MCPError('MCP session must have client sampling capabilities');
}
}
/**
* Generate text using MCP session sampling
* @param {object} options - Generation options
* @param {Array} options.prompt - AI SDK prompt format
* @param {AbortSignal} options.abortSignal - Abort signal
* @returns {Promise<object>} Generation result in AI SDK format
*/
async doGenerate(options) {
try {
// Convert AI SDK prompt to MCP format
const { messages, systemPrompt } = convertToMCPFormat(options.prompt);
// Use MCP session.requestSampling (same as MCPRemoteProvider)
const response = await this.session.requestSampling(
{
messages,
systemPrompt,
temperature: this.settings.temperature,
maxTokens: this.settings.maxTokens,
includeContext: 'thisServer'
},
{
// signal: options.abortSignal,
timeout: 240000 // 4 minutes timeout
}
);
// Convert MCP response back to AI SDK format
const result = convertFromMCPFormat(response);
return {
text: result.text,
finishReason: result.finishReason || 'stop',
usage: {
promptTokens: result.usage?.inputTokens || 0,
completionTokens: result.usage?.outputTokens || 0,
totalTokens:
(result.usage?.inputTokens || 0) + (result.usage?.outputTokens || 0)
},
rawResponse: response,
warnings: result.warnings
};
} catch (error) {
throw mapMCPError(error);
}
}
/**
* Generate structured object using MCP session sampling
* @param {object} options - Generation options
* @param {Array} options.prompt - AI SDK prompt format
* @param {import('zod').ZodSchema} options.schema - Zod schema for validation
* @param {string} [options.mode='json'] - Generation mode ('json' or 'tool')
* @param {AbortSignal} options.abortSignal - Abort signal
* @returns {Promise<object>} Generation result with structured object
*/
async doGenerateObject(options) {
try {
const { schema, mode = 'json', ...restOptions } = options;
if (!schema) {
throw new MCPError('Schema is required for object generation');
}
// Convert schema to JSON instructions
const objectName = restOptions.objectName || 'generated_object';
const jsonInstructions = convertSchemaToInstructions(schema, objectName);
// Enhance prompt with JSON generation instructions
const enhancedPrompt = enhancePromptForJSON(
options.prompt,
jsonInstructions
);
// Convert enhanced prompt to MCP format
const { messages, systemPrompt } = convertToMCPFormat(enhancedPrompt);
// Use MCP session.requestSampling with enhanced prompt
const response = await this.session.requestSampling(
{
messages,
systemPrompt,
temperature: this.settings.temperature,
maxTokens: this.settings.maxTokens,
includeContext: 'thisServer'
},
{
timeout: 240000 // 4 minutes timeout
}
);
// Convert MCP response back to AI SDK format
const result = convertFromMCPFormat(response);
// Extract JSON from the response text
const jsonText = extractJson(result.text);
// Parse and validate JSON
let parsedObject;
try {
parsedObject = JSON.parse(jsonText);
} catch (parseError) {
throw new MCPError(
`Failed to parse JSON response: ${parseError.message}. Response: ${result.text.substring(0, 200)}...`
);
}
// Validate against schema
try {
const validatedObject = schema.parse(parsedObject);
return {
object: validatedObject,
finishReason: result.finishReason || 'stop',
usage: {
promptTokens: result.usage?.inputTokens || 0,
completionTokens: result.usage?.outputTokens || 0,
totalTokens:
(result.usage?.inputTokens || 0) +
(result.usage?.outputTokens || 0)
},
rawResponse: response,
warnings: result.warnings
};
} catch (validationError) {
throw new MCPError(
`Generated object does not match schema: ${validationError.message}. Generated: ${JSON.stringify(parsedObject, null, 2)}`
);
}
} catch (error) {
throw mapMCPError(error);
}
}
/**
* Stream text generation using MCP session sampling
* Note: MCP may not support native streaming, so this may simulate streaming
* @param {object} options - Generation options
* @returns {AsyncIterable} Stream of generation chunks
*/
async doStream(options) {
try {
// For now, simulate streaming by chunking the complete response
// TODO: Implement native streaming if MCP supports it
const result = await this.doGenerate(options);
// Create async generator that yields chunks
return this.simulateStreaming(result);
} catch (error) {
throw mapMCPError(error);
}
}
/**
* Simulate streaming by chunking a complete response
* @param {object} result - Complete generation result
* @returns {AsyncIterable} Simulated stream chunks
*/
async *simulateStreaming(result) {
const text = result.text;
const chunkSize = Math.max(1, Math.floor(text.length / 10)); // 10 chunks
for (let i = 0; i < text.length; i += chunkSize) {
const chunk = text.slice(i, i + chunkSize);
const isLast = i + chunkSize >= text.length;
yield {
type: 'text-delta',
textDelta: chunk
};
// Small delay to simulate streaming
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Final chunk with finish reason and usage
yield {
type: 'finish',
finishReason: result.finishReason,
usage: result.usage
};
}
}

View File

@@ -0,0 +1,116 @@
/**
* src/ai-providers/custom-sdk/mcp/message-converter.js
*
* Message conversion utilities for converting between AI SDK prompt format
* and MCP sampling format.
*/
/**
* Convert AI SDK prompt format to MCP sampling format
* @param {Array} prompt - AI SDK prompt array
* @returns {object} MCP format with messages and systemPrompt
*/
export function convertToMCPFormat(prompt) {
const messages = [];
let systemPrompt = '';
for (const message of prompt) {
if (message.role === 'system') {
// Extract system prompt
systemPrompt = extractTextContent(message.content);
} else if (message.role === 'user' || message.role === 'assistant') {
// Convert user/assistant messages
messages.push({
role: message.role,
content: {
type: 'text',
text: extractTextContent(message.content)
}
});
}
}
return {
messages,
systemPrompt
};
}
/**
* Convert MCP response format to AI SDK format
* @param {object} response - MCP sampling response
* @returns {object} AI SDK compatible result
*/
export function convertFromMCPFormat(response) {
// Handle different possible response formats
let text = '';
let usage = null;
let finishReason = 'stop';
let warnings = [];
if (typeof response === 'string') {
text = response;
} else if (response.content) {
text = extractTextContent(response.content);
usage = response.usage;
finishReason = response.finishReason || 'stop';
} else if (response.text) {
text = response.text;
usage = response.usage;
finishReason = response.finishReason || 'stop';
} else {
// Fallback: try to extract text from response
text = JSON.stringify(response);
warnings.push('Unexpected MCP response format, used JSON fallback');
}
return {
text,
usage,
finishReason,
warnings
};
}
/**
* Extract text content from various content formats
* @param {string|Array|object} content - Content in various formats
* @returns {string} Extracted text
*/
function extractTextContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
// Handle array of content parts
return content
.map((part) => {
if (typeof part === 'string') {
return part;
}
if (part.type === 'text' && part.text) {
return part.text;
}
if (part.text) {
return part.text;
}
// Skip non-text content (images, etc.)
return '';
})
.filter((text) => text.length > 0)
.join(' ');
}
if (content && typeof content === 'object') {
if (content.type === 'text' && content.text) {
return content.text;
}
if (content.text) {
return content.text;
}
}
// Fallback
return String(content || '');
}

View File

@@ -0,0 +1,150 @@
/**
* @fileoverview Schema conversion utilities for MCP AI SDK provider
*/
/**
* Convert Zod schema to human-readable JSON instructions
* @param {import('zod').ZodSchema} schema - Zod schema object
* @param {string} [objectName='result'] - Name of the object being generated
* @returns {string} Instructions for JSON generation
*/
export function convertSchemaToInstructions(schema, objectName = 'result') {
try {
// Generate example structure from schema
const exampleStructure = generateExampleFromSchema(schema);
return `
CRITICAL JSON GENERATION INSTRUCTIONS:
You must respond with ONLY valid JSON that matches this exact structure for "${objectName}":
${JSON.stringify(exampleStructure, null, 2)}
STRICT REQUIREMENTS:
1. Response must start with { and end with }
2. Use double quotes for all strings and property names
3. Do not include any text before or after the JSON
4. Do not wrap in markdown code blocks
5. Do not include explanations or comments
6. Follow the exact property names and types shown above
7. All required fields must be present
Begin your response immediately with the opening brace {`;
} catch (error) {
// Fallback to basic JSON instructions if schema parsing fails
return `
CRITICAL JSON GENERATION INSTRUCTIONS:
You must respond with ONLY valid JSON for "${objectName}".
STRICT REQUIREMENTS:
1. Response must start with { and end with }
2. Use double quotes for all strings and property names
3. Do not include any text before or after the JSON
4. Do not wrap in markdown code blocks
5. Do not include explanations or comments
Begin your response immediately with the opening brace {`;
}
}
/**
* Generate example structure from Zod schema
* @param {import('zod').ZodSchema} schema - Zod schema
* @returns {any} Example object matching the schema
*/
function generateExampleFromSchema(schema) {
// This is a simplified schema-to-example converter
// For production, you might want to use a more sophisticated library
if (!schema || typeof schema._def === 'undefined') {
return {};
}
const def = schema._def;
switch (def.typeName) {
case 'ZodObject':
const result = {};
const shape = def.shape();
for (const [key, fieldSchema] of Object.entries(shape)) {
result[key] = generateExampleFromSchema(fieldSchema);
}
return result;
case 'ZodString':
return 'string';
case 'ZodNumber':
return 0;
case 'ZodBoolean':
return false;
case 'ZodArray':
const elementExample = generateExampleFromSchema(def.type);
return [elementExample];
case 'ZodOptional':
return generateExampleFromSchema(def.innerType);
case 'ZodNullable':
return generateExampleFromSchema(def.innerType);
case 'ZodEnum':
return def.values[0] || 'enum_value';
case 'ZodLiteral':
return def.value;
case 'ZodUnion':
// Use the first option from the union
if (def.options && def.options.length > 0) {
return generateExampleFromSchema(def.options[0]);
}
return 'union_value';
case 'ZodRecord':
return {
key: generateExampleFromSchema(def.valueType)
};
default:
// For unknown types, return a placeholder
return `<${def.typeName || 'unknown'}>`;
}
}
/**
* Enhance prompt with JSON generation instructions
* @param {Array} prompt - AI SDK prompt array
* @param {string} jsonInstructions - JSON generation instructions
* @returns {Array} Enhanced prompt array
*/
export function enhancePromptForJSON(prompt, jsonInstructions) {
const enhancedPrompt = [...prompt];
// Find system message or create one
let systemMessageIndex = enhancedPrompt.findIndex(
(msg) => msg.role === 'system'
);
if (systemMessageIndex >= 0) {
// Append to existing system message
const currentContent = enhancedPrompt[systemMessageIndex].content;
enhancedPrompt[systemMessageIndex] = {
...enhancedPrompt[systemMessageIndex],
content: currentContent + '\n\n' + jsonInstructions
};
} else {
// Add new system message at the beginning
enhancedPrompt.unshift({
role: 'system',
content: jsonInstructions
});
}
return enhancedPrompt;
}