Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ This repository contains practical examples demonstrating how to use the Univers
For detailed documentation and API reference, visit:
- [Python UTCP repository](https://github.com/universal-tool-calling-protocol/python-utcp)
- [TypeScript UTCP repository](https://github.com/universal-tool-calling-protocol/typescript-utcp)

## Featured Examples

- TypeScript: GraphQL over HTTP (GitHub GraphQL)
- Path: `typescript/graphql_http_example`
- One-liner: Demonstrates modeling GraphQL operations as UTCP HTTP tools with an OpenAI-driven client, including a generic GraphQL executor and a repo search tool using variables.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions typescript/graphql_http_example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
## TypeScript UTCP - GraphQL via HTTP Provider (GitHub GraphQL)

This example shows how to call a GraphQL API (GitHub GraphQL) using UTCP's HTTP provider. It defines a UTCP manual that models a generic GraphQL query tool and uses a bearer token loaded from `.env` for authentication.

### Prerequisites
- Node.js 18+
- A GitHub Personal Access Token with `public_repo` (or relevant) scopes

### Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` from `example.env` and set your environment variables:
```bash
cp example.env .env
```

### Files
- `graphql_manual.json`: UTCP manual with GraphQL-powered tools
- `github.graphql_query`: Execute any GitHub GraphQL query
- `github.search_repos`: Search public repositories by keywords (convenience wrapper over GraphQL search)
- `llm_client_openai.ts`: OpenAI-driven client that lets the LLM decide the query and variables (and use `github.search_repos` for discovery)
- `package.json`, `tsconfig.json`: Project configuration

### Key benefits
- **Zero server scaffolding**: Model GraphQL operations as UTCP tools without writing any backend glue. The manual captures auth, headers, and body shape once; every consumer benefits.
- **LLM-ready by construction**: Tools are discoverable, have JSON schemas, and can be surfaced to an LLM for autonomous tool use (query selection, variable filling, and retries).
- **Security by configuration**: Tokens and headers are injected via variable loaders (`.env` here). Swap in a secret manager later without touching call sites.
- **Portable across stacks**: The exact same manual can be used by different clients (TS/Python) and different LLM backends (OpenAI, Bedrock) with no API rewrites.


### Running with LLM variant (OpenAI)
1. Ensure `.env` has both `GITHUB_TOKEN` and `OPENAI_API_KEY`.
2. Run the LLM client:
```bash
npm run start:llm
```
3. Try prompts like:
- "What is my login?"
- "Search public repos for 'utcp' and list top 5 with URLs"
- "Find repositories mentioning 'universal tool calling' (any owner) and show name + URL"
- "Limit to my account: search my repos for 'agent' in the name"

### Testing without hitting GitHub
1. Install dev deps (already in `package.json`): `npm i`
2. Run tests: `npm test`
3. We use `nock` to mock `https://api.github.com/graphql` so queries resolve locally.

The client supports multi-step tool use. For repo discovery:
- The model can call `github.graphql_query` to get `viewer.login`, then use `github.search_repos` with either global keywords or `user:LOGIN` scoping.


3 changes: 3 additions & 0 deletions typescript/graphql_http_example/example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GITHUB_TOKEN=
OPENAI_API_KEY=

92 changes: 92 additions & 0 deletions typescript/graphql_http_example/graphql_manual.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"version": "0.1.0",
"tools": [
{
"name": "github.graphql_query",
"description": "Execute a GraphQL query against the GitHub GraphQL API.",
"tags": ["graphql", "github"],
"tool_provider": {
"name": "github_graphql",
"provider_type": "http",
"url": "https://api.github.com/graphql",
"http_method": "POST",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
},
"inputs": {
"type": "object",
"properties": {
"body": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "GraphQL query string" },
"variables": { "type": "object", "additionalProperties": true }
},
"required": ["query"]
}
},
"required": ["body"]
},
"outputs": {
"type": "object",
"properties": {
"data": { "type": "object", "additionalProperties": true },
"errors": { "type": "array", "items": { "type": "object" } }
}
}
},
{
"name": "github.search_repos",
"description": "Search public GitHub repositories by keywords using the GraphQL search API. Provide body.query and body.variables; defaults are prefilled.",
"tags": ["graphql", "github", "search"],
"tool_provider": {
"name": "github_graphql",
"provider_type": "http",
"url": "https://api.github.com/graphql",
"http_method": "POST",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
},
"inputs": {
"type": "object",
"properties": {
"body": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "GraphQL search query; defaults to a repository search over name field.",
"default": "query ($q: String!, $first: Int!) {\n search(query: $q, type: REPOSITORY, first: $first) {\n repositoryCount\n nodes {\n ... on Repository { name url owner { login } description }\n }\n }\n}"
},
"variables": {
"type": "object",
"description": "Variables for the search query: q and first.",
"properties": {
"q": { "type": "string", "description": "Search string, e.g., 'keywords in:name' or 'user:LOGIN keywords in:name'" },
"first": { "type": "integer", "description": "Max results (1..50)", "default": 10 }
},
"required": ["q"]
}
},
"required": ["query", "variables"]
}
},
"required": ["body"]
},
"outputs": {
"type": "object",
"properties": {
"data": { "type": "object", "additionalProperties": true },
"errors": { "type": "array", "items": { "type": "object" } }
}
}
}
]
}

224 changes: 224 additions & 0 deletions typescript/graphql_http_example/llm_client_openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import * as path from 'path';
import * as readline from 'readline';
import dotenv from 'dotenv';
import { OpenAI } from 'openai';
import type { ChatCompletionMessageParam } from 'openai/resources';

import { UtcpClient } from '@utcp/sdk/dist/src/client/utcp-client.js';
import { UtcpClientConfigSchema } from '@utcp/sdk/dist/src/client/utcp-client-config.js';
import { TextProviderSchema } from '@utcp/sdk/dist/src/shared/provider.js';
import type { TextProvider } from '@utcp/sdk/dist/src/shared/provider.js';
import type { Tool } from '@utcp/sdk/dist/src/shared/tool.js';

function createReadline() {
return readline.createInterface({ input: process.stdin, output: process.stdout });
}

function ask(rl: readline.Interface, prompt: string) {
return new Promise<string>(resolve => rl.question(prompt, resolve));
}

function sanitizeToolsForPrompt(tools: Tool[]): any[] {
// Redact provider and headers; expose only safe, schema-relevant fields
return tools.map((t: any) => ({
name: t?.name,
description: t?.description,
tags: t?.tags,
inputs: t?.inputs,
outputs: t?.outputs
}));
}

function formatToolsForPrompt(tools: Tool[]): string {
return JSON.stringify(sanitizeToolsForPrompt(tools), null, 2);
}

function normalizeArguments(arguments_: any): any {
// Ensure we always send { body: { query, variables? } }
if (arguments_ && typeof arguments_ === 'object') {
if (arguments_.body && typeof arguments_.body === 'object') {
return { body: arguments_.body };
}
const { query, variables } = arguments_;
const body: any = {};
if (typeof query === 'string') body.query = query;
if (variables && typeof variables === 'object') body.variables = variables;
if (Object.keys(body).length > 0) return { body };
}
return arguments_;
}

async function initializeClient(): Promise<UtcpClient> {
const manualProvider: TextProvider = TextProviderSchema.parse({
name: 'github',
provider_type: 'text',
file_path: './graphql_manual.json'
});

const client = await UtcpClient.create(UtcpClientConfigSchema.parse({
variables: {},
load_variables_from: [
{
type: 'dotenv',
env_file_path: path.join(process.cwd(), '.env')
}
]
}));

await client.register_tool_provider(manualProvider);
return client;
}

// Using OpenAI function calling; no regex extraction

// Direct tool invocation; tools are fully defined in the manual
async function callTool(utcpClient: UtcpClient, toolName: string, args: any) {
return await utcpClient.call_tool(toolName, args);
}

async function main() {
dotenv.config({ path: path.join(process.cwd(), '.env') });
if (!process.env.OPENAI_API_KEY) {
console.error('Missing OPENAI_API_KEY in .env');
process.exit(1);
}
if (!process.env.GITHUB_TOKEN) {
console.error('Missing GITHUB_TOKEN in .env');
process.exit(1);
}

const utcpClient = await initializeClient();
const tools = await utcpClient.search_tools('');
const toolsJson = formatToolsForPrompt(tools);

const systemPrompt =
'You are a helpful assistant with access to two tools: a generic GraphQL executor and a convenience repository search tool. ' +
'When you need to use a tool, respond ONLY with a JSON object with keys "tool_name" and "arguments". ' +
'Do not add any other text. The "arguments" must be a JSON object. ' +
'For repository discovery, call github.search_repos with {"body": {"query": "query ($q: String!, $first: Int!) {\n search(query: $q, type: REPOSITORY, first: $first) {\n repositoryCount\n nodes {\n ... on Repository { name url owner { login } description }\n }\n }\n}", "variables": {"q": "keywords in:name", "first": N}}}. ' +
'For custom GraphQL, use github.graphql_query. ' +
`Available tools:\n${toolsJson}`;

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const rl = createReadline();
const history: ChatCompletionMessageParam[] = [];
const MAX_HISTORY_MESSAGES = 20;

try {
while (true) {
const userPrompt = await ask(rl, "\nEnter your request (or 'exit'): ");
if (userPrompt.toLowerCase() === 'exit' || userPrompt.toLowerCase() === 'quit') break;

// Start a tool/answer loop allowing multiple tool invocations
let messages: ChatCompletionMessageParam[] = [
{ role: 'system', content: systemPrompt },
...history,
{ role: 'user', content: userPrompt }
];

while (true) {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
tools: [
{
type: 'function',
function: {
name: 'call_utcp_tool',
description: 'Call a UTCP tool by name with arguments. Arguments should match the tool schema.',
parameters: {
type: 'object',
properties: {
tool_name: { type: 'string' },
arguments: { type: 'object', additionalProperties: true }
},
required: ['tool_name', 'arguments']
}
}
}
]
});

const choice = response.choices[0];
const assistantMsg: any = choice.message;
const toolCalls = assistantMsg.tool_calls || [];
const assistantContent = assistantMsg.content || '';

if (!toolCalls.length) {
console.log('Assistant:', assistantContent);
history.push({ role: 'user', content: userPrompt }, { role: 'assistant', content: assistantContent });
if (history.length > MAX_HISTORY_MESSAGES) {
history.splice(0, history.length - MAX_HISTORY_MESSAGES);
}
break;
}

const newMessages: ChatCompletionMessageParam[] = [
{ role: 'system', content: systemPrompt },
...history,
{ role: 'user', content: userPrompt },
assistantMsg as ChatCompletionMessageParam
];

for (const call of toolCalls) {
if (call.type !== 'function' || call.function?.name !== 'call_utcp_tool') continue;
const payloadText = call.function?.arguments || '{}';
let payload: any = {};
try {
payload = JSON.parse(payloadText);
} catch {
console.error('Failed to parse tool arguments JSON');
continue;
}

const toolName: string = payload.tool_name;
const args = normalizeArguments(payload.arguments);
console.log(`\nExecuting: ${toolName} with args: ${JSON.stringify(args, null, 2)}`);

let toolOutput = '';
try {
const result: any = await callTool(utcpClient, toolName, args);
const errors: any[] | undefined = Array.isArray(result?.errors) ? result.errors : undefined;
if (errors && errors.length > 0) {
const summaries = errors.map(e => (e?.message ?? JSON.stringify(e))).slice(0, 3);
console.error(`GraphQL errors (${errors.length}): ${summaries.join(' | ')}`);
toolOutput = JSON.stringify({
ok: false,
error_count: errors.length,
error_messages: errors.map(e => e?.message ?? String(e)),
data: result?.data ?? null,
errors
});
} else {
toolOutput = JSON.stringify({ ok: true, data: result?.data ?? result });
}
} catch (e: any) {
toolOutput = `Error calling ${toolName}: ${e?.message || String(e)}`;
console.error(toolOutput);
}

newMessages.push({
role: 'tool',
// @ts-expect-error: tool_call_id is a valid property for tool role in OpenAI tools
tool_call_id: call.id,
content: toolOutput
} as any);

if (history.length > MAX_HISTORY_MESSAGES) {
history.splice(0, history.length - MAX_HISTORY_MESSAGES);
}
}

messages = newMessages;
}
}
} finally {
rl.close();
}
}

// Direct invocation for ES modules
main().catch(err => {
console.error('Error in main:', err);
process.exit(1);
});
Loading