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>
102 lines
4.9 KiB
TypeScript
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();
|