| 1 | /** Definition proposals only. Rust owns the sole MCP catalog, transport and auth. */ |
| 2 | import { Service, type Context } from '@deepseek-ai/cordis' |
| 3 | import type { RpcPeer } from '../rpc.ts' |
| 4 | import { isJson } from '../json.ts' |
| 5 | import { OwnedRegistrations, type OwnedEntry, type OwnerBase } from './owned.ts' |
| 6 | export interface McpDefinition { readonly serverName: string; readonly server: Record<string, unknown> } |
| 7 | export interface LocalMcp<O extends OwnerBase = OwnerBase> extends OwnedEntry<O> {} |
| 8 | export function normalizeMcp(value: unknown): McpDefinition { |
| 9 | if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k=>k!=='serverName'&&k!=='server')) throw new TypeError('MCP needs serverName and server') |
| 10 | const {serverName,server}=value as McpDefinition |
| 11 | if (typeof serverName!=='string'||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(serverName)||!server||Array.isArray(server)||typeof server!=='object'||!isJson(server)) throw new TypeError('MCP definition must be bounded plain JSON') |
| 12 | // Literal credential-bearing fields must never enter the host protocol. |
| 13 | for(const key of ['headers','env']) if(server[key]!==undefined && (!server[key]||typeof server[key]!=='object'||Array.isArray(server[key])||Object.keys(server[key] as object).length)) throw new TypeError('MCP credentials remain under Rust authentication authority') |
| 14 | if(typeof server.url==='string') { |
| 15 | const url=new URL(server.url) |
| 16 | if(url.username||url.password||url.search||url.hash) throw new TypeError('MCP endpoint credentials and query data remain under Rust authority') |
| 17 | } |
| 18 | if(Buffer.byteLength(JSON.stringify(server))>64*1024) throw new RangeError('MCP definition exceeds 64 KiB') |
| 19 | return Object.freeze({serverName,server:structuredClone(server)}) |
| 20 | } |
| 21 | export class McpDefinitions<O extends OwnerBase> { |
| 22 | private readonly registrations:OwnedRegistrations<O,LocalMcp<O>> |
| 23 | private readonly owners=new Map<O,Map<string,()=>void>>() |
| 24 | constructor(rpc:RpcPeer,ownedBy:(owner:O)=>Map<number,LocalMcp<O>>,warn:(message:string,owner:O)=>void) { this.registrations=new OwnedRegistrations(rpc,'mcp_server',ownedBy,warn) } |
| 25 | register(owner:O,value:McpDefinition):()=>void { |
| 26 | const {serverName,server}=normalizeMcp(value) |
| 27 | const definitions=this.owners.get(owner)??new Map<string,()=>void>() |
| 28 | if(definitions.has(serverName)) throw new Error('MCP server is already registered in this entry; dispose it first') |
| 29 | if(definitions.size>=64) throw new RangeError('MCP entry exceeds 64 servers') |
| 30 | const undo=this.registrations.add({owner,name:serverName,disposed:false},{name:serverName,description:JSON.stringify(server)}) |
| 31 | const dispose=()=>{if(definitions.get(serverName)!==dispose)return;definitions.delete(serverName);if(!definitions.size)this.owners.delete(owner);undo()} |
| 32 | definitions.set(serverName,dispose);this.owners.set(owner,definitions);return dispose |
| 33 | } |
| 34 | forget(owner:O) {for(const dispose of [...(this.owners.get(owner)?.values()??[])])dispose();this.registrations.forget(owner)} |
| 35 | } |
| 36 | export function defineMcpService<O extends OwnerBase>(host:{ownerOf(ctx:Context):O|undefined,definitions:McpDefinitions<O>}) { |
| 37 | class McpShim extends Service { |
| 38 | constructor(ctx:Context){super(ctx,'mcp')} |
| 39 | registerServer(definition:McpDefinition):()=>void { |
| 40 | const owner=host.ownerOf(this.ctx);if(!owner)throw new Error('MCP proposal is outside an extension owner') |
| 41 | const normalized=normalizeMcp(definition) |
| 42 | return this.ctx.effect(()=>host.definitions.register(owner,normalized),`mcp.registerServer(${JSON.stringify(normalized.serverName)})`) |
| 43 | } |
| 44 | } |
| 45 | Object.freeze(McpShim.prototype);return McpShim |
| 46 | } |
| 47 |