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>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
1 changed files with 69131 additions and 9001 deletions
102
examples/stickynotes/client.ts
Normal file
102
examples/stickynotes/client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Reference in a new issue