Source index

src/context.mjs

const PROTOCOL = '2025-03-26';

export function parseRpc(body, contentType = '') {
  if (!contentType.includes('text/event-stream')) return JSON.parse(body);
  const messages = body.split(/\r?\n\r?\n/).map(block =>
    block.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n')
  ).filter(Boolean).map(data => JSON.parse(data));
  const response = messages.findLast(message => message.result !== undefined || message.error);
  if (!response) throw new Error('Context returned no JSON-RPC result.');
  return response;
}

export class ContextClient {
  constructor({url = process.env.SANITY_CONTEXT_URL, token = process.env.SANITY_CONTEXT_TOKEN, fetchImpl = fetch} = {}) {
    const endpoint = new URL(url || 'https://invalid.example');
    if (endpoint.protocol !== 'https:' || endpoint.hostname !== 'api.sanity.io' || endpoint.username || endpoint.password || !endpoint.pathname.startsWith('/v1/context/organizations/'))
      throw new Error('Set an official HTTPS Sanity Context MCP endpoint.');
    if (!token) throw new Error('Set SANITY_CONTEXT_TOKEN in the server environment.');
    this.url = endpoint.href; this.token = token; this.fetch = fetchImpl; this.id = 0;
  }
  async rpc(method, params, notification = false) {
    const headers = {'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', Authorization: `Bearer ${this.token}`, 'MCP-Protocol-Version': PROTOCOL};
    if (this.session) headers['Mcp-Session-Id'] = this.session;
    const response = await this.fetch(this.url, {
      method: 'POST', headers, signal: AbortSignal.timeout(45000),
      body: JSON.stringify({jsonrpc: '2.0', ...(notification ? {} : {id: ++this.id}), method, ...(params ? {params} : {})}),
    });
    if (!response.ok) {
      let detail = '';
      try {
        const error = await response.json();
        const message = error.error?.message || error.message;
        if (typeof message === 'string') detail = message.split(this.token).join('[credential omitted]').replace(/Bearer\s+\S+/gi, 'Bearer [omitted]').slice(0, 500);
      } catch {}
      throw new Error(`Sanity Context HTTP ${response.status}${detail ? ': ' + detail : '; response omitted'}.`);
    }
    if (response.headers.get('mcp-session-id')) this.session = response.headers.get('mcp-session-id');
    const body = await response.text();
    if (notification || !body.trim()) return;
    const data = parseRpc(body, response.headers.get('content-type') || '');
    if (data.error) throw new Error(`Sanity Context JSON-RPC ${data.error.code}: ${String(data.error.message).split(this.token).join('[credential omitted]').replace(/Bearer\s+\S+/gi, 'Bearer [omitted]').slice(0, 300)}`);
    return data.result;
  }
  async connect() {
    const info = await this.rpc('initialize', {protocolVersion: PROTOCOL, capabilities: {}, clientInfo: {name: 'access-atlas', version: '0.1.0'}});
    await this.rpc('notifications/initialized', {}, true);
    const listed = await this.rpc('tools/list');
    this.tools = listed.tools || [];
    if (!this.tools.length) throw new Error('Context MCP has no tools. Check deployed schema and endpoint sources.');
    return {server: info.serverInfo, tools: this.tools};
  }
  async call(name, args) {
    if (!this.tools.some(tool => tool.name === name)) throw new Error('Tool is not in the read-only Context tool list.');
    const result = await this.rpc('tools/call', {name, arguments: args});
    return result;
  }
}