typescript-sdk/examples/stickynotes/client.ts
Felix Weinberger 1823aae891
Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 14:01:10 +01:00

102 lines
4.9 KiB
TypeScript

/**
* Drives the sticky-notes board end to end: add two notes, list/read their
* resources, remove one, then — on the 2025-era leg — attempt `remove_all`
* three ways (cancel, accept-unchecked, accept-confirmed) to prove the board is
* cleared only on an explicit confirmation.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
interface AddResult {
id: string;
uri: string;
}
interface RemoveAllResult {
status: string;
removed: number;
}
const { transport, url, era } = parseExampleArgs();
const client = new Client(
{ name: 'stickynotes-example-client', version: '1.0.0' },
{
versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' },
capabilities: { elicitation: { form: {} } }
}
);
await (transport === 'stdio'
? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }))
: client.connect(new StreamableHTTPClientTransport(new URL(url))));
let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel';
client.setRequestHandler('elicitation/create', async () => {
if (elicitAnswer === 'cancel') return { action: 'cancel' };
return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } };
});
// ADD two notes.
const first = await client.callTool({ name: 'add_note', arguments: { text: 'Buy milk' } });
const firstNote = first.structuredContent as unknown as AddResult;
check.match(firstNote.uri, /^note:\/\/\//);
const second = await client.callTool({ name: 'add_note', arguments: { text: 'Walk the dog' } });
const secondNote = second.structuredContent as unknown as AddResult;
check.notEqual(firstNote.id, secondNote.id);
// LIST/READ — both notes should be listable resources.
const list = await client.listResources();
const noteUris = new Set(list.resources.filter(r => r.uri.startsWith('note:///')).map(r => r.uri));
check.ok(noteUris.has(firstNote.uri) && noteUris.has(secondNote.uri));
const read = await client.readResource({ uri: firstNote.uri });
const readContent = read.contents[0];
check.equal(readContent && 'text' in readContent ? readContent.text : '', 'Buy milk');
// REMOVE ONE.
const removed = await client.callTool({ name: 'remove_note', arguments: { id: firstNote.id } });
check.equal((removed.structuredContent as { removed?: boolean } | undefined)?.removed, true);
const after = await client.listResources();
check.ok(!after.resources.some(r => r.uri === firstNote.uri));
// The elicitation-confirmed `remove_all` path is 2025-era stdio only:
// push-style server→client requests need a long-lived bidirectional
// connection that saw the `initialize` handshake (so the client's
// elicitation capability is advertised and the response can route back to
// the same server instance). On a 2026-07-28 connection there is no
// server→client request channel, and over `createMcpHandler`'s default
// stateless legacy fallback each HTTP request is a fresh per-request
// server — the equivalent is multi-round-trip `inputRequired` (see
// ../elicitation/).
if (era === 'modern' || transport === 'http') {
const removedSecond = await client.callTool({ name: 'remove_note', arguments: { id: secondNote.id } });
check.equal((removedSecond.structuredContent as { removed?: boolean } | undefined)?.removed, true);
const afterClear = await client.listResources();
check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0);
} else {
// CANCEL — board untouched.
elicitAnswer = 'cancel';
const cancelled = await client.callTool({ name: 'remove_all' });
check.equal((cancelled.structuredContent as unknown as RemoveAllResult).status, 'cancelled');
const afterCancel = await client.listResources();
check.ok(afterCancel.resources.some(r => r.uri === secondNote.uri));
// UNCHECKED — accept with confirm:false → declined, board untouched.
elicitAnswer = 'unchecked';
const declined = await client.callTool({ name: 'remove_all' });
check.equal((declined.structuredContent as unknown as RemoveAllResult).status, 'declined');
// CONFIRM — accept with confirm:true → cleared.
elicitAnswer = 'confirm';
const cleared = await client.callTool({ name: 'remove_all' });
check.equal((cleared.structuredContent as unknown as RemoveAllResult).status, 'cleared');
check.equal((cleared.structuredContent as unknown as RemoveAllResult).removed, 1);
const afterClear = await client.listResources();
check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0);
// EMPTY — a follow-up remove_all reports 'empty' without eliciting.
const empty = await client.callTool({ name: 'remove_all' });
check.equal((empty.structuredContent as unknown as RemoveAllResult).status, 'empty');
}
await client.close();