Guide · MCP Protocol Primitives
MCP Server Argument Completions — autocomplete for tool and prompt parameters
MCP's argument completion feature lets clients show an autocomplete dropdown as users type parameter values. When a user starts typing a customer ID in Claude Desktop or an IDE extension, the client sends a completion/complete request to your server with the partial value. Your server queries its data and returns a ranked list of matching completions. The LLM or user selects from the list, which reduces typos, prevents invalid IDs from reaching your tool handlers, and surfaces valid options without requiring the user to know the exact value in advance. This guide covers the completion/complete protocol, handler implementation, database-backed dynamic completions, performance constraints, and testing your completion path.
TL;DR
Declare { completions: {} } in your server capabilities. Handle CompleteRequestSchema with a handler that receives the reference (tool name + argument name, or prompt name + argument name) and the partial value, then returns up to 100 matching suggestions. Keep each completion handler under 200ms — clients show suggestions on keystroke and will abandon stale responses. Index the columns you autocomplete against. Return hasMore: true when results are truncated so clients know there are more options beyond the returned list.
What completions enable
Argument completion bridges the gap between free-form parameter entry and validated input:
| Without completions | With completions |
|---|---|
| User types a customer ID from memory; typos cause tool call failures | Client shows matching customer IDs as user types; selection inserts exact value |
| LLM guesses a slug from a prior conversation message | LLM requests completions, picks the exact match, tool call succeeds first time |
| Enum arguments show no options until the tool schema is read | Completions surface valid enum values dynamically from live data |
| Invalid IDs only fail at tool call time, after the round-trip | Invalid IDs are filtered out at completion time; invalid input never reaches the handler |
Clients that support completions typically trigger them when a parameter accepts a string and the user has typed at least one character. Clients that don't support completions simply skip the completion/complete request — your server continues to work correctly without them.
Declaring completion support
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
const server = new Server(
{ name: 'my-mcp-server', version: '1.0.0' },
{
capabilities: {
tools: { listChanged: true },
completions: {} // declare completion support
}
}
);
Declaring completions: {} tells clients they can send completion/complete requests. If you declare it but your handler throws MethodNotFound, clients treat this as a protocol error. Either implement the handler or don't declare the capability.
Implementing the completion handler
import { CompleteRequestSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(CompleteRequestSchema, async ({ params }) => {
const ref = params.ref; // { type: 'ref/tool', name: 'tool_name', argument: 'arg_name' }
// or { type: 'ref/prompt', name: 'prompt_name', argument: 'arg_name' }
const partialValue = params.argument.value; // what the user has typed so far
// Route by reference type and tool/prompt name
if (ref.type === 'ref/tool') {
return await completeTool(ref.name, ref.argument, partialValue);
}
if (ref.type === 'ref/prompt') {
return await completePrompt(ref.name, ref.argument, partialValue);
}
return { completion: { values: [], hasMore: false } };
});
async function completeTool(
toolName: string,
argumentName: string,
partial: string
): Promise<{ completion: { values: string[]; hasMore: boolean } }> {
if (toolName === 'get_customer' && argumentName === 'customer_id') {
return await completeCustomerId(partial);
}
if (toolName === 'update_order' && argumentName === 'order_id') {
return await completeOrderId(partial);
}
if (toolName === 'assign_tag' && argumentName === 'tag_name') {
return await completeTagName(partial);
}
// No completions for this tool/argument combination
return { completion: { values: [], hasMore: false } };
}
Database-backed dynamic completions
const COMPLETION_LIMIT = 20;
async function completeCustomerId(partial: string): Promise<{
completion: { values: string[]; hasMore: boolean }
}> {
// Use a prefix-match query — keep the result tight
const rows = await db.query(
`SELECT id, name FROM customers
WHERE id ILIKE $1 OR name ILIKE $1
ORDER BY name ASC
LIMIT $2`,
[`${partial}%`, COMPLETION_LIMIT + 1] // fetch one extra to detect hasMore
);
const hasMore = rows.rowCount > COMPLETION_LIMIT;
const values = rows.rows
.slice(0, COMPLETION_LIMIT)
// Format as 'id (name)' so the LLM/user has context when selecting
.map(r => `${r.id} (${r.name})`);
return { completion: { values, hasMore } };
}
async function completeTagName(partial: string): Promise<{
completion: { values: string[]; hasMore: boolean }
}> {
// Enum-style completions from a tags table
const rows = await db.query(
`SELECT name FROM tags
WHERE name ILIKE $1
ORDER BY usage_count DESC, name ASC
LIMIT $2`,
[`%${partial}%`, COMPLETION_LIMIT + 1]
);
const hasMore = rows.rowCount > COMPLETION_LIMIT;
return {
completion: {
values: rows.rows.slice(0, COMPLETION_LIMIT).map(r => r.name),
hasMore
}
};
}
Always index the columns you use in completion queries. A LIKE 'prefix%' query on an unindexed 10M-row table takes 2+ seconds — far outside the completion latency budget. For prefix matching on string IDs: CREATE INDEX ON customers(id text_pattern_ops). For full-text name matching: CREATE INDEX ON customers USING gin(to_tsvector('english', name)) or a simple trigram index with pg_trgm.
Completion for prompt arguments
async function completePrompt(
promptName: string,
argumentName: string,
partial: string
): Promise<{ completion: { values: string[]; hasMore: boolean } }> {
if (promptName === 'review_pull_request' && argumentName === 'pr_number') {
// Suggest recent open PRs
const prs = await github.pulls.list({
owner, repo,
state: 'open',
per_page: 20
});
const matches = prs.data.filter(pr =>
String(pr.number).startsWith(partial) ||
pr.title.toLowerCase().includes(partial.toLowerCase())
);
return {
completion: {
values: matches.slice(0, 10).map(pr => `${pr.number} — ${pr.title}`),
hasMore: matches.length > 10
}
};
}
if (promptName === 'review_pull_request' && argumentName === 'focus') {
// Static enum completion — these don't change
const options = ['security', 'performance', 'style', 'all'];
const matches = options.filter(o => o.startsWith(partial.toLowerCase()));
return { completion: { values: matches, hasMore: false } };
}
return { completion: { values: [], hasMore: false } };
}
Performance requirements
Completion responses must arrive within the client's keystroke debounce window — typically 150–300ms total round-trip. Server processing budget: under 100ms to leave margin for network latency. Three constraints follow from this:
- No unindexed table scans. Every column in your completion WHERE clause must have an appropriate index. Test with
EXPLAIN ANALYZEand confirm the query uses an index scan, not a sequential scan. - No external API calls. Don't call GitHub, Stripe, or any external API in a completion handler — the external API latency blows the budget. Cache the data you need for completions (a 30-second Redis cache of recent PRs works; staleness at that TTL is acceptable for autocomplete).
- Limit result set size. Return at most 20–50 values. A dropdown with 100 items is slower to render and harder to navigate than one with 10. Rank by relevance (most recently used, highest usage count, closest string distance) and return the best matches.
// Add a p95 latency check to your health endpoint
app.get('/health/completions', async (req, res) => {
const start = Date.now();
try {
await completeCustomerId('test');
const latencyMs = Date.now() - start;
const healthy = latencyMs < 100;
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'degraded',
latency_ms: latencyMs,
threshold_ms: 100
});
} catch (e) {
res.status(503).json({ status: 'down', error: (e as Error).message });
}
});
Testing completions
import { describe, it, expect } from 'vitest';
import { createTestClient } from './test-helpers.js';
describe('argument completions', () => {
it('completes customer_id prefix', async () => {
const client = await createTestClient();
const result = await client.complete({
ref: { type: 'ref/tool', name: 'get_customer', argument: 'customer_id' },
argument: { value: 'cus_4' }
});
expect(result.completion.values.length).toBeGreaterThan(0);
// All values should start with the prefix
result.completion.values.forEach(v => {
expect(v.toLowerCase()).toContain('cus_4');
});
// hasMore should be accurate
if (result.completion.values.length === 20) {
expect(typeof result.completion.hasMore).toBe('boolean');
}
});
it('returns empty array for unknown tool', async () => {
const client = await createTestClient();
const result = await client.complete({
ref: { type: 'ref/tool', name: 'nonexistent_tool', argument: 'some_param' },
argument: { value: 'abc' }
});
expect(result.completion.values).toEqual([]);
expect(result.completion.hasMore).toBe(false);
});
it('responds within 100ms', async () => {
const client = await createTestClient();
const start = Date.now();
await client.complete({
ref: { type: 'ref/tool', name: 'get_customer', argument: 'customer_id' },
argument: { value: 'c' }
});
expect(Date.now() - start).toBeLessThan(100);
});
});
Frequently asked questions
Should completions be paginated like resource lists?
No — completions use the hasMore: boolean flag rather than a cursor. Clients don't paginate completion results; they just know whether there are more matches than were returned. If hasMore is true, the client typically shows the results with a "type more to narrow" hint. Completions are not designed for browsing — they're for narrowing a known-large set to a short list of likely candidates. If the user needs to browse all 10,000 customers, that's a search_customers tool call, not a completion.
Can I return rich objects (label + value pairs) rather than strings?
The MCP spec defines completion values as strings only. You can encode additional display context by formatting the string: "cus_421 (Acme Corp)" gives the user both the ID and the company name in the dropdown. The client inserts the full string as the argument value when selected — you need to parse out the ID in your tool handler using a split or regex. This is a known limitation; pure-string completions are simpler to implement but can't carry structured metadata.
Do completions work for numeric parameters?
The completion/complete protocol only handles string arguments. Numeric parameters (schema type number or integer) are not completed by the protocol. For numeric IDs that happen to be stored as integers (order_id, user_id), convert to strings at the completion layer — return ["12345", "12346"] — and accept the string in your tool's Zod schema with z.coerce.number().
How do I handle completions for context-dependent parameters?
The completion/complete request includes the reference (tool + argument name) and the partial value, but not the values of other arguments in the same call. If your completion is context-dependent — for example, completing a product_id that depends on the already-selected customer_id — you can't read customer_id from the completion request. Design around this: either make the context-dependent parameter a tool call (search_products_for_customer) rather than an argument completion, or cache the session's recent selections and use them as a soft filter in the completion query.
Further reading
- MCP Capabilities Negotiation — declaring completion support
- MCP Server Tool Discovery — writing argument descriptions that guide completion UX
- MCP Server Prompts — completing prompt arguments
- MCP Server Testing — integration tests for the completion handler
- MCP Server Caching — caching external API data for sub-100ms completions