Skip to content

Commit 4c85bc7

Browse files
WebMCP: Implement tool title
Per webmachinelearning/webmcp#152, this CL implements the `title` property for registered tools, consistent with MCP's tool definition [1]. This is done by adding a new `title` property to the `ModelContextTool` dictionary for imperative tools, and a new `tooltitle` attribute for declarative tools. A subsequent spec PR will be made to spec the `getTools()` API and the reflect the fact that `RegisteredTool#title` is always non-undefined, and is the empty string when not provided on registration time, per some discussion we had about the right API shape. [1]: https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool R=masonf Bug: 489045948 Change-Id: If848ba85ba1b4aeaa39834eca11ba91108e493c0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7742044 Commit-Queue: Dominic Farolino <dom@chromium.org> Reviewed-by: Mason Freed <masonf@chromium.org> Cr-Commit-Position: refs/heads/main@{#1636305}
1 parent 5646d2a commit 4c85bc7

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<title>WebMCP: toolchange event and getTools() update on form attribute mutation</title>
4+
<link rel="author" href="mailto:dom@chromium.org">
5+
<script src="/resources/testharness.js"></script>
6+
<script src="/resources/testharnessreport.js"></script>
7+
<script src="../resources/helpers.js"></script>
8+
<body>
9+
10+
<form id="f1" toolname="my_tool" tooltitle="My Title" tooldescription="desc">
11+
<input id="input1" type="text" name="input_name">
12+
</form>
13+
14+
<script>
15+
function waitForToolChange() {
16+
return new Promise(resolve => {
17+
navigator.modelContext.addEventListener('toolchange', resolve, { once: true });
18+
});
19+
}
20+
21+
promise_test(async t => {
22+
const form = document.getElementById('f1');
23+
24+
// Wait for initial registration to complete.
25+
await waitForTool('my_tool');
26+
27+
let tools = await navigator.modelContext.getTools();
28+
assert_equals(tools.length, 1, "Initially registered tool");
29+
assert_equals(tools[0].name, 'my_tool');
30+
assert_equals(tools[0].title, 'My Title');
31+
assert_equals(tools[0].description, 'desc');
32+
33+
// Test updating tooltitle attribute
34+
{
35+
const changePromise = waitForToolChange();
36+
form.setAttribute('tooltitle', 'New Title');
37+
await changePromise;
38+
39+
let tools = await navigator.modelContext.getTools();
40+
assert_equals(tools.length, 1, "Still one tool");
41+
assert_equals(tools[0].title, 'New Title', "Title is updated");
42+
}
43+
44+
// Test updating tooldescription attribute
45+
{
46+
const changePromise = waitForToolChange();
47+
form.setAttribute('tooldescription', 'New Description');
48+
await changePromise;
49+
50+
let tools = await navigator.modelContext.getTools();
51+
assert_equals(tools.length, 1);
52+
assert_equals(tools[0].description, 'New Description', "Description is updated");
53+
}
54+
55+
// Test updating toolname attribute
56+
{
57+
const changePromise = waitForToolChange();
58+
form.setAttribute('toolname', 'new_tool_name');
59+
await changePromise;
60+
61+
let tools = await navigator.modelContext.getTools();
62+
assert_equals(tools.length, 1);
63+
assert_equals(tools[0].name, 'new_tool_name', "Name is updated");
64+
}
65+
66+
// Test removing tooltitle attribute (should fall back to empty string)
67+
{
68+
const changePromise = waitForToolChange();
69+
form.removeAttribute('tooltitle');
70+
await changePromise;
71+
72+
let tools = await navigator.modelContext.getTools();
73+
assert_equals(tools.length, 1);
74+
assert_equals(tools[0].title, '', "Title should fall back to empty string");
75+
}
76+
77+
// Test removing tooldescription attribute (should unregister the tool since it's required)
78+
{
79+
const changePromise = waitForToolChange();
80+
form.removeAttribute('tooldescription');
81+
await changePromise;
82+
83+
let tools = await navigator.modelContext.getTools();
84+
assert_equals(tools.length, 0, "Tool should be unregistered when description is removed");
85+
}
86+
87+
// Restore the description attribute to re-register the tool
88+
{
89+
const changePromise = waitForToolChange();
90+
form.setAttribute('tooldescription', 'Restored Description');
91+
await changePromise;
92+
93+
let tools = await navigator.modelContext.getTools();
94+
assert_equals(tools.length, 1);
95+
assert_equals(tools[0].name, 'new_tool_name');
96+
assert_equals(tools[0].description, 'Restored Description');
97+
}
98+
99+
// Test removing toolname attribute (should unregister the tool since it's required)
100+
{
101+
const changePromise = waitForToolChange();
102+
form.removeAttribute('toolname');
103+
await changePromise;
104+
105+
let tools = await navigator.modelContext.getTools();
106+
assert_equals(tools.length, 0, "Tool should be unregistered when name is removed");
107+
}
108+
}, "Test that mutating or removing form attributes (toolname, tooldescription, tooltitle) fires toolchange and updates getTools() correctly");
109+
</script>
110+
</body>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Tool title is exposed correctly</title>
5+
<meta name="timeout" content="long">
6+
<link rel="author" href="mailto:dom@chromium.org">
7+
<script src="/resources/testharness.js"></script>
8+
<script src="/resources/testharnessreport.js"></script>
9+
<script src="/common/get-host-info.sub.js"></script>
10+
<script src="resources/helpers.js"></script>
11+
</head>
12+
<body>
13+
<script>
14+
promise_test(async () => {
15+
navigator.modelContext.registerTool({
16+
name: 'with-title',
17+
title: 'My Custom Title',
18+
description: 'tool with title',
19+
execute: async () => 'empty'
20+
});
21+
22+
const tools = await navigator.modelContext.getTools();
23+
const tool = tools.find(t => t.name === 'with-title');
24+
25+
assert_equals(tool.title, 'My Custom Title', 'Title is preserved');
26+
}, 'Tool title is preserved when provided');
27+
28+
promise_test(async () => {
29+
navigator.modelContext.registerTool({
30+
name: 'without-title',
31+
description: 'tool without title',
32+
execute: async () => 'empty'
33+
});
34+
35+
const tools = await navigator.modelContext.getTools();
36+
const tool = tools.find(t => t.name === 'without-title');
37+
38+
// If not provided upon registration, `title` should be empty string in the
39+
// dictionary.
40+
assert_equals(tool.title, '', 'Title is empty string when not provided');
41+
}, 'Missing tool title in registration is exposed as empty string');
42+
43+
promise_test(async () => {
44+
// \uD800 is an unpaired high surrogate.
45+
navigator.modelContext.registerTool({
46+
name: 'with-unpaired-surrogate',
47+
title: 'Title with \uD800 unpaired surrogate',
48+
description: 'tool with unpaired surrogate in title',
49+
execute: async () => 'empty'
50+
});
51+
52+
const tools = await navigator.modelContext.getTools();
53+
const tool = tools.find(t => t.name === 'with-unpaired-surrogate');
54+
55+
// USVString replaces unpaired surrogates with U+FFFD.
56+
assert_equals(tool.title, 'Title with \uFFFD unpaired surrogate');
57+
}, 'Tool title with unpaired surrogate is fixed up by USVString');
58+
</script>
59+
</body>
60+
</html>

0 commit comments

Comments
 (0)