Files
awesome-copilot/extensions/connector-namespaces/test/safe-tools.mjs
T
github-actions[bot] d171be5b52 chore: publish from main
2026-07-16 00:30:12 +00:00

169 lines
7.7 KiB
JavaScript

// Safe read-tool selection for the MCP smoke harness.
//
// We never blindly call the first tool a server advertises — many servers lead
// with mutating tools (SendMail, CreateItem, DeleteMessage). This module picks a
// tool that is safe to call automatically: read-only by name, with an input
// schema we can satisfy using benign placeholder values. If nothing qualifies,
// the caller records the server as SKIPPED (tools proven to load, no call made).
// Curated overrides keyed by a predicate over the server identity. Use this when
// the heuristic would skip a server that actually has a known-safe read tool, or
// when we want to pin a specific tool/argument set. `tool` must match a tool name
// from tools/list; `args` is merged over the auto-filled args.
const CURATED = [
{
name: "Microsoft Learn Docs",
match: (s) => /learn|docs/i.test(s.apiName) || /learn|docs/i.test(s.displayName),
tool: "microsoft_docs_search",
args: { query: "azure connectors", question: "what are azure connectors" },
},
{
name: "WorkIQ Teams",
match: (s) => /teams/i.test(s.apiName) || /teams/i.test(s.displayName),
tool: "ListTeams",
args: {},
},
{
// SearchMessagesQueryParameters declares queryParameters optional, but the
// server enforces "queryParameters OR nextLink required" — a cross-field
// rule the JSON schema doesn't express, so the no-arg heuristic call fails.
// Pin a benign read-only OData query that returns at most one message.
name: "WorkIQ Mail",
match: (s) => /outlookmail/i.test(s.apiName) || /mail/i.test(s.displayName),
tool: "SearchMessagesQueryParameters",
args: { queryParameters: "?$top=1" },
},
{
// copilot_chat is the only tool Work IQ Copilot exposes. Its name doesn't
// match the read-only heuristic, so without this pin the harness would skip
// the call. A benign one-sentence question is a safe read-style call.
name: "WorkIQ Copilot",
match: (s) => /copilotchat/i.test(s.apiName) || /copilot/i.test(s.displayName),
tool: "copilot_chat",
args: { message: "What is Microsoft Azure? Answer in one sentence." },
},
{
// Word advertises CreateDocument/AddComment/ReplyToComment (all mutating)
// and GetDocumentContent, which needs a real document on a drive this
// tenant can't resolve ("Invalid hostname for this tenancy" — a backend
// config issue, not an argument problem). No safe no-arg read tool exists,
// so prove the tools load and skip the call instead of a false failure.
name: "WorkIQ Word",
match: (s) => /wordmcp/i.test(s.apiName) || /^word\b/i.test(s.displayName || ""),
skip: true,
reason: "no safe no-arg read tool (GetDocumentContent needs a real document + a tenant drive)",
},
];
const READ_ONLY_NAME = /^(list|get|search|read|find|describe|show|fetch|lookup|query|count)/i;
// Names that look read-only but are known to mutate or are too risky to auto-run.
const DENY_NAME = /(send|create|update|delete|remove|add|set|post|put|patch|write|upload|move|copy|rename|share|invite|reply|forward|draft|flag|ingest|execute|run|trigger)/i;
function isFillableSchema(schema) {
if (!schema || typeof schema !== "object") return true;
const required = Array.isArray(schema.required) ? schema.required : [];
const props = schema.properties || {};
for (const key of required) {
const prop = props[key];
if (!prop) return false; // required but undescribed → can't satisfy safely
if (!fillValue(prop, key).ok) return false;
}
return true;
}
// Produce a benign placeholder for a single property schema.
function fillValue(prop, key) {
if (!prop || typeof prop !== "object") return { ok: true, value: "test" };
if (prop.default !== undefined) return { ok: true, value: prop.default };
if (Array.isArray(prop.enum) && prop.enum.length > 0) return { ok: true, value: prop.enum[0] };
const type = Array.isArray(prop.type) ? prop.type.find((t) => t !== "null") : prop.type;
switch (type) {
case "string": {
if (/mail|email|upn|recipient|to|address/i.test(key)) return { ok: true, value: "test@example.com" };
if (/url|uri|link/i.test(key)) return { ok: true, value: "https://example.com" };
if (/query|search|q|term|keyword|text|question|prompt/i.test(key)) return { ok: true, value: "azure" };
return { ok: true, value: "test" };
}
case "number":
case "integer":
return { ok: true, value: 1 };
case "boolean":
return { ok: true, value: false };
default:
// object/array/unknown required input → we can't safely fabricate it.
return { ok: false };
}
}
// Build the argument object for a tool from its required input schema.
function buildArgs(tool) {
const schema = tool.inputSchema || tool.input_schema || {};
const required = Array.isArray(schema.required) ? schema.required : [];
const props = schema.properties || {};
const args = {};
for (const key of required) {
const filled = fillValue(props[key] || {}, key);
if (!filled.ok) return { ok: false };
args[key] = filled.value;
}
return { ok: true, args };
}
// Pick a safe tool to call for a server.
// server: { apiName, displayName, configName }
// tools: the array returned by tools/list
// → { tool, args, source } | null (null means "no safe tool, record SKIPPED")
export function pickSafeTool(server, tools) {
if (!Array.isArray(tools) || tools.length === 0) return null;
const byName = new Map(tools.map((t) => [t.name, t]));
// 1. Curated override: a skip directive, or a pinned tool if it's advertised.
for (const entry of CURATED) {
if (!entry.match(server)) continue;
if (entry.skip) {
return { skip: true, reason: entry.reason || "curated skip", source: "curated-skip" };
}
if (byName.has(entry.tool)) {
const tool = byName.get(entry.tool);
const built = buildArgs(tool);
const base = built.ok ? built.args : {};
return { tool: entry.tool, args: { ...base, ...entry.args }, source: "curated" };
}
}
// 2. Heuristic. Two passes, preferring tools we can call with NO fabricated
// arguments — a required `id`/`resourceId` filled with a placeholder will
// pass the schema check but fail at runtime (fake message id, fake
// resource). A read tool with no required args is both safer and far more
// likely to actually succeed, so try those first.
const candidate = (tool, requireEmpty) => {
const nm = tool.name || "";
const annotations = tool.annotations || {};
if (annotations.readOnlyHint !== true || annotations.destructiveHint === true) return null;
if (!READ_ONLY_NAME.test(nm)) return null;
if (DENY_NAME.test(nm)) return null;
const schema = tool.inputSchema || tool.input_schema || {};
const required = Array.isArray(schema.required) ? schema.required : [];
if (requireEmpty && required.length > 0) return null;
if (!isFillableSchema(schema)) return null;
const built = buildArgs(tool);
if (!built.ok) return null;
return { tool: nm, args: built.args, source: requireEmpty ? "heuristic-noargs" : "heuristic" };
};
for (const tool of tools) {
const pick = candidate(tool, true);
if (pick) return pick;
}
for (const tool of tools) {
const pick = candidate(tool, false);
if (pick) return pick;
}
return null;
}
export const _internals = { buildArgs, isFillableSchema, fillValue, READ_ONLY_NAME, DENY_NAME };